diff --git a/README.md b/README.md index 7933e54da..2ba4d9d94 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ Each supported language ships the same eight: The `getting-started` skill is the entry point: it directs the agent to clone and grep the real SDK source, so every fact is grounded in the actual contract rather than model knowledge. -Across the marketplace that's **72 language packs and 648 skills**. See +Across the marketplace that's **72 language packs and 696 skills**. See [`plugins/paypal/README.md`](plugins/paypal/README.md) for a worked example. ## Repository structure diff --git a/plugins/adyen/.claude-plugin/plugin.json b/plugins/adyen/.claude-plugin/plugin.json index 7cbb5f5a7..abf682de3 100644 --- a/plugins/adyen/.claude-plugin/plugin.json +++ b/plugins/adyen/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "adyen", "displayName": "Adyen", - "version": "0.2.1", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Adyen SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/adyen/.cursor-plugin/plugin.json b/plugins/adyen/.cursor-plugin/plugin.json index 7cbb5f5a7..abf682de3 100644 --- a/plugins/adyen/.cursor-plugin/plugin.json +++ b/plugins/adyen/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "adyen", "displayName": "Adyen", - "version": "0.2.1", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Adyen SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/adyen/plugin.json b/plugins/adyen/plugin.json index 7cbb5f5a7..abf682de3 100644 --- a/plugins/adyen/plugin.json +++ b/plugins/adyen/plugin.json @@ -1,7 +1,7 @@ { "name": "adyen", "displayName": "Adyen", - "version": "0.2.1", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Adyen SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/adyen/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/adyen/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..8744555be 100644 --- a/plugins/adyen/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/adyen/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,245 +1,104 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Adyen .NET SDK in C# — this API authenticates with HTTP Basic, an API key in a header and an API key in a query parameter, and this skill gives the credential each scheme takes, the AdyenClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Adyen .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with HTTP Basic, an API key in a header and an API key in a query parameter. APIMatic surfaces each scheme as a **nullable credentials property** on `AdyenClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) +| Scheme | Property on `AdyenClientOptions` | Credential | +| --- | --- | --- | +| HTTP Basic | `BasicAuth` | `BasicAuthCredentials` | +| API key — `X-API-Key` header | `ApiKeyAuth` | `string` | +| API key — `clientKey` query parameter | `ClientKey` | `string` | -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). +**That table is the entire authentication surface of this SDK.** `Adyen.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. ## Basic auth ```csharp -using {RootNamespace}.Core.Authentication.Basic; +using Adyen.Core.Authentication.Basic; -options.{BasicAuthProperty} = new BasicAuthCredentials +options.BasicAuth = new BasicAuthCredentials { Username = "...", Password = "..." }; ``` -Sends `Authorization: Basic base64(username:password)`. - -## Bearer token - -Set the configured token property on the options class to your access-token string: - -```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; -``` - -Sends `Authorization: Bearer ACCESS_TOKEN`. - -## API key (header, query, or cookie) - -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: +Sends `Authorization: Basic base64(username:password)`. Both halves are required — a username with an empty password is misconfigured, not partially configured. -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` +## API key (header) -## OAuth 2.0 — client credentials (machine-to-machine) +The key travels in the `X-API-Key` request header. The header name is fixed by the generated scheme — you supply only the value: ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; - -options.{OAuthProperty} = new OAuth2ClientCredentials -{ - ClientId = "...", - ClientSecret = "...", - Scope = "..." // optional -}; +options.ApiKeyAuth = "API_KEY"; ``` -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. +## API key (query parameter) -## OAuth 2.0 — authorization code (3-legged, with PKCE) +The key is appended to every request as the `clientKey` query parameter. The parameter name is fixed by the generated scheme — you supply only the value: ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; - -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials -{ - ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE - RedirectUri = "https://app.example.com/callback", - Scope = "...", // optional - State = "...", // optional CSRF token - Pkce = PkceMethod.S256, // default; RFC 7636 - PromptForAuthorizationCode = async (authorizationUrl, ct) => - { - // Open/redirect the browser to authorizationUrl, then return the - // authorization code your redirect endpoint received. - return await GetCodeFromUserAsync(authorizationUrl, ct); - } -}; +options.ClientKey = "API_KEY"; ``` -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. +⚠ **A query-string credential is logged by everything on the path.** Proxies, CDNs, load balancers and the provider's own access logs record full URLs, and this SDK's request logging is no exception. Treat the key as exposed to your infrastructure, and rotate it on that basis. -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: +## Combining the schemes -| PKCE | token request built | no secret ⇒ | -| --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | +This SDK generates more than one credentials property, and its operations compose them. You configure the composition by setting the relevant properties on `AdyenClientOptions` — the generated client wires the rest. -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. +**OR (`AuthSchemeAny`) — alternatives, tried in order.** Schemes with **no credentials configured are skipped, not tried**; the first configured scheme that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails. Configure exactly the one you intend to use — an extra credential set "just in case" changes which scheme authenticates the call. -## OAuth 2.0 — resource owner password +## Operations that need no credentials -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; - -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` - -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. - -### Supplying your own token source - -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. - -```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) -``` - -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: - -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). - -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. - -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +Some operations on this API are public: the generated endpoint uses `NoneAuthScheme` and sends no credential, whatever you set on `AdyenClientOptions`. You still construct the same client — there is no separate unauthenticated client — and the credentials you configured are simply not applied to those calls. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `AdyenClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("AdyenClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.Username)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "AdyenClient:Username is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"AdyenClient:Username is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +Check every value **the scheme you chose** needs. The rows below are not one checklist: the schemes above are alternatives, so a row belonging to a scheme you are not using needs no value at all, and demanding one at startup blocks a boot that would have worked. Validate the rows whose property you set, and no others: + +| Value to validate | Feeds `AdyenClientOptions` property | +| --- | --- | +| `Username` | `BasicAuth` | +| `Password` | `BasicAuth` | +| `ApiKeyAuth` | `ApiKeyAuth` | +| `ClientKey` | `ClientKey` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddAdyenClient(options => ...)` callback when registering via DI. `AdyenClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddAdyenClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/adyen/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/adyen/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/adyen/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/adyen/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/adyen/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/adyen/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/adyen/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/adyen/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/adyen/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/adyen/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/adyen/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/adyen/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/adyen/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/adyen/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/adyen/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/adyen/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/adyen/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/adyen/skills/dotnet/dotnet-getting-started/SKILL.md index cb40d2afb..745417096 100644 --- a/plugins/adyen/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/adyen/skills/dotnet/dotnet-getting-started/SKILL.md @@ -11,7 +11,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity @@ -26,7 +26,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any | Auth | **API key** in the `X-API-Key` header — set `ApiKeyAuth`
HTTP **Basic** — set `BasicAuth`
**API key** in the `clientKey` query parameter — set `ClientKey` | | Environments | `ServerEnvironment.Test` *(default)* → `Default` `https://checkout-test.adyen.com/v72`, `Default1` `https://pal-test.adyen.com/pal/servlet/Payment/v68`, `Default2` `https://pal-test.adyen.com/pal/servlet/Recurring/v68`, `Default3` `https://pal-test.adyen.com/pal/servlet/Payout/v68`, `Default4` `https://pal-test.adyen.com/pal/servlet/BinLookup/v54`, `Default5` `https://pal-test.adyen.com/pal/servlet/StoredValue/v46`, `Default6` `https://ca-test.adyen.com/ca/services/DataProtectionService/v1`, `Default7` `https://balanceplatform-api-test.adyen.com/fx/api/v1`, `Default8` `https://pal-test.adyen.com/pal/services/TestCard/v1`, `Default9` `https://management-test.adyen.com/v3`, `Default10` `https://cal-test.adyen.com/cal/services/Account/v6`, `Default11` `https://balance-control-test.adyen.com/balance-control/api/v2`, `Default12` `https://cal-test.adyen.com/cal/services/Notification/v6`, `Default13` `https://balanceplatform-api-test.adyen.com/bcl/v2`, `Default14` `https://balanceplatform-api-test.adyen.com/btl/v4`, `Default15` `https://balanceplatform-api-test.adyen.com/capital/v1`, `Default16` `https://cal-test.adyen.com/cal/services/Fund/v6`, `Default17` `https://test.adyen.com/authe/api/v1`, `Default18` `https://kyc-test.adyen.com/lem/v4`, `Default19` `https://cal-test.adyen.com/cal/services/Hop/v6`, `Default20` `https://balanceplatform-api-test.adyen.com/a2aissuer-api/v1`, `Default21` `https://obgateway-test.adyen.com/obgateway/v1`, `Default22` `https://ca-test.adyen.com/ca/services/DisputeService/v30`, `Default23` `https://balanceplatform-api-test.adyen.com/btl/api/v4`, `Default24` `https://device-api-test.adyen.com/v1`, `Default25` `https://softposconfig-test.adyen.com/softposconfig/v3`, `Default26` `https://management-test.adyen.com/v1`, `Default27` `https://checkout-test.adyen.com/checkout/possdk/v68`, `Default28` `https://postfmapi-test.adyen.com/postfmapi/terminal/v1` | | Log environment variable | `ADYENCLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-adyen` makes that binding); the **Integration workflow** below says which governs which step. @@ -44,22 +44,24 @@ Models, enums, union types and the generated error types each sit in their own c ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Adyen, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/Adyen/Models/`, `src/Adyen/Errors/` and `src/Adyen/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/Adyen/Models/`, enums `src/Adyen/Models/Enums/`, typed errors `src/Adyen/Errors/`, request records `src/Adyen/Requests/`, or `src/Adyen/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: @@ -69,7 +71,7 @@ Keep lookups cheap — the rules that keep a session's context small: ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/adyen-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/adyen-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. **Clone once, up front**, into a fresh timestamped folder under `/adyen-sdk-src/`, and reuse that folder for the rest of your session: @@ -87,30 +89,31 @@ git clone --filter=blob:none --branch main https://github.com/context-plugins/ad # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/adyen-sdk-src/`, **never** in the project repo, and its path never goes into `adyen-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `adyen-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `AdyenClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/Adyen/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/Adyen/Models/` (+ `src/Adyen/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/Adyen/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/Adyen/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/Adyen/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/Adyen/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/Adyen/Servers/`, `src/Adyen/AdyenClient.cs`, `src/Adyen/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Adyen may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything @@ -126,7 +129,7 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/Adyen/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step @@ -134,9 +137,9 @@ You loaded every companion the sheet named before starting (per `dotnet-integrat 1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new AdyenClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) 2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `AdyenClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/adyen/skills/dotnet/dotnet-integrate-adyen/SKILL.md b/plugins/adyen/skills/dotnet/dotnet-integrate-adyen/SKILL.md index b8a5598af..1f499748b 100644 --- a/plugins/adyen/skills/dotnet/dotnet-integrate-adyen/SKILL.md +++ b/plugins/adyen/skills/dotnet/dotnet-integrate-adyen/SKILL.md @@ -11,7 +11,7 @@ You are the Adyen .NET SDK specialist for this task — one agent for every SDK ## Grounding rules -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `adyen-plan.md` — the plan must stay portable. +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `adyen-plan.md` never carries its path — the plan must stay portable. - **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. - **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. - **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. @@ -29,7 +29,7 @@ Your FIRST deliverable is `adyen-plan.md` at `/adyen-plan.md` #### How to ground (map-first, one pass) 1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. 3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. 4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. 5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. @@ -38,7 +38,7 @@ Your FIRST deliverable is `adyen-plan.md` at `/adyen-plan.md` These need no SDK knowledge and touch no project file, so do them before or alongside the map work: -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); - the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; - locating the SDK reference and its version in the project; @@ -54,10 +54,23 @@ Never use this prerequisite work as a "head start" on implementation: **creating #### The `adyen-plan.md` format (keep it tight — tables, not prose) 1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + 3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. 6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. 7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. @@ -67,12 +80,46 @@ Never use this prerequisite work as a "head start" on implementation: **creating | 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | | 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | | 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | | 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | | 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | | 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. @@ -95,14 +142,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. 3. After every change: `dotnet build`; fix non-SDK errors yourself. 4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `Adyen.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. ### Step 4 — Fixing SDK errors (map-first, in place) A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `Adyen.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/Adyen/Models/{Type}.cs`, `src/Adyen/Models/Enums/{Type}.cs`, `src/Adyen/Errors/{Type}.cs`, `src/Adyen/Requests/{Type}.cs`, or `src/Adyen/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. 3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". 4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). 5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. @@ -120,6 +168,6 @@ A standalone Adyen question with no code change: load `dotnet-getting-started`, - **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. - **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. - **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search Adyen for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `Adyen` is what the generated source declares, not what public docs describe. +- **Do not web-search Adyen for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `Adyen` is what the generated source declares, not what public docs describe. - **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. diff --git a/plugins/adyen/skills/dotnet/dotnet-models/SKILL.md b/plugins/adyen/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/adyen/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/adyen/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/adyen/skills/dotnet/dotnet-models/reference.md b/plugins/adyen/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/adyen/skills/dotnet/dotnet-models/reference.md +++ b/plugins/adyen/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/adyen/skills/dotnet/dotnet-testing/SKILL.md b/plugins/adyen/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/adyen/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/adyen/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/adyen/skills/python/python-authentication/SKILL.md b/plugins/adyen/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/adyen/skills/python/python-authentication/SKILL.md +++ b/plugins/adyen/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/adyen/skills/python/python-calling-endpoints/SKILL.md b/plugins/adyen/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/adyen/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/adyen/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/adyen/skills/python/python-client-initialization/SKILL.md b/plugins/adyen/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/adyen/skills/python/python-client-initialization/SKILL.md +++ b/plugins/adyen/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/adyen/skills/python/python-configuration-resilience/SKILL.md b/plugins/adyen/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/adyen/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/adyen/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/adyen/skills/python/python-error-handling/SKILL.md b/plugins/adyen/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/adyen/skills/python/python-error-handling/SKILL.md +++ b/plugins/adyen/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/adyen/skills/python/python-event-streams/SKILL.md b/plugins/adyen/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/adyen/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/adyen/skills/python/python-file-handling/SKILL.md b/plugins/adyen/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/adyen/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/adyen/skills/python/python-getting-started/SKILL.md b/plugins/adyen/skills/python/python-getting-started/SKILL.md index 32294f973..bd63405bc 100644 --- a/plugins/adyen/skills/python/python-getting-started/SKILL.md +++ b/plugins/adyen/skills/python/python-getting-started/SKILL.md @@ -25,12 +25,12 @@ Verified against `adyen/` and `pyproject.toml` of the generated package at versi | Version | `72` | | Sync client class | `AdyenClient` (alias `Client`) | | Async client class | `AsyncAdyenClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `server_config` · `timeout` (default `30.0`) · `api_key_auth` · `basic_auth` · `client_key`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Client construction | **keyword-only**: `server_config` · `timeout` (default `30.0`) · `retry_options` · `api_key_auth` · `basic_auth` · `client_key`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | **API key** in the `X-API-Key` header — set `api_key_auth` · HTTP **Basic** — set `basic_auth` · **API key** in the `clientKey` query parameter — set `client_key` | | Environments | one environment; 29 named servers configured through `server_config` | | Base-URL config | `ServerConfig` (`adyen/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | | Surface | 449 operations across 92 controllers, plus 18 directly on the client (`map/operations/client.md`) · 2046 models · 21 unions · 522 enums · 420 per-operation error unions | @@ -39,13 +39,18 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash pip install "adyen @ git+https://github.com/context-plugins/adyen-python-sdk.git@main" +python -c "import adyen, pathlib; print(pathlib.Path(adyen.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules @@ -258,15 +263,15 @@ The SDK map's controller table carries the same counts and links to a page per c The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `adyen/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `adyen/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `adyen/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/adyen-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. Every `Source` path on the map is relative to that SDK root, so `adyen/models/ach_direct_debit.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. @@ -286,7 +291,7 @@ Read the one module that owns the fact **inside the installed package**. Locate python -c "import adyen, pathlib; print(pathlib.Path(adyen.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/adyen` on Windows, `.venv/lib/python3.*/site-packages/adyen` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/adyen` on Windows, `.venv/lib/python3.*/site-packages/adyen` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -294,7 +299,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/ad | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -304,7 +309,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/ad | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | | An operation's error mapper (status → schema) | `errors/_error.py` | @@ -321,13 +326,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The three schemes are `api_key_auth=`, `basic_auth=` and `client_key=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; **30 operations return `None`**, so `with_raw_response` is the only way to observe their status code; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`), so an unknown wire value passes through as a plain `str` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -346,8 +351,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 6. `CheckoutErrorResponseEntity` — 1 operation (`utility.post_validate_shopper_id`); distinguishing members `error_code` · `error_type` · `message` 7. *(none)* — `abort_request` · `admin_request` · `balance_inquiry_request` · `card_acquisition_request` · `card_reader_apdu_request` · `diagnosis_request` · `display_request` · `enable_service_request` · `get_totals_request` · `input_request` · `login_request` · `logout_request` · `payment_request` · `print_request` · `reconciliation_request` · `reversal_request` · `stored_value_request` · `transaction_status_request` · `account_holders.get_account_holders_id_tax_form_summary` · `general.post_sessions3` · `legal_entities.post_legal_entities_id_request_periodic_review` · `payments.get_sessions_session_id` · `payments.post_card_details` · `payments.post_sessions` · `recurring_api.delete_stored_payment_methods_stored_payment_method_id` · `recurring_api.get_stored_payment_methods` · `recurring_api.post_forward` · `recurring_api.post_stored_payment_methods` · `utility.post_apple_pay_sessions`: no typed arm, so `.error` is always `RawError` 8. So `isinstance(e.error, RestServiceError)` matches only 192 of 449 operations. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 231 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which server each call resolves against**, set through `server_config`. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 231 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which server each call resolves against**, set through `server_config`. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/adyen/skills/python/python-integrate-adyen/SKILL.md b/plugins/adyen/skills/python/python-integrate-adyen/SKILL.md index 8d3323ea2..4ca8d6341 100644 --- a/plugins/adyen/skills/python/python-integrate-adyen/SKILL.md +++ b/plugins/adyen/skills/python/python-integrate-adyen/SKILL.md @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `python-getting-started` is your **lookup layer - **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import adyen, pathlib; print(pathlib.Path(adyen.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import adyen, pathlib; print(pathlib.Path(adyen.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) - **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. @@ -40,6 +40,21 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** creating or activating a virtual environment, installing `adyen` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. @@ -62,8 +77,9 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. 4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `adyen.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `adyen.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. ### Step 3 — Answering pure questions diff --git a/plugins/adyen/skills/python/python-models/SKILL.md b/plugins/adyen/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/adyen/skills/python/python-models/SKILL.md +++ b/plugins/adyen/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/adyen/skills/python/python-models/reference.md b/plugins/adyen/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/adyen/skills/python/python-models/reference.md +++ b/plugins/adyen/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/adyen/skills/python/python-testing/SKILL.md b/plugins/adyen/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/adyen/skills/python/python-testing/SKILL.md +++ b/plugins/adyen/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/adyen/skills/typescript/typescript-authentication/SKILL.md b/plugins/adyen/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/adyen/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/adyen/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/adyen/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/adyen/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/adyen/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/adyen/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/adyen/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/adyen/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/adyen/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/adyen/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/adyen/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/adyen/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/adyen/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/adyen/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/adyen/skills/typescript/typescript-error-handling/SKILL.md b/plugins/adyen/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/adyen/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/adyen/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/alpaca/skills/typescript/typescript-getting-started/SKILL.md b/plugins/alpaca/skills/typescript/typescript-getting-started/SKILL.md index 5992f7399..4eb44e157 100644 --- a/plugins/alpaca/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/alpaca/skills/typescript/typescript-getting-started/SKILL.md @@ -1,11 +1,11 @@ --- name: "typescript-getting-started" -description: "Trader API TypeScript SDK identity and lookup layer (TypeScript/JavaScript only) — install, the single import specifier `trader-api`, the server environments and the base-URL knob, the auth pattern, the SDK map that ships inside the installed package (`sdk-map.md` + `map/operations/`) and how to traverse it, and the file table naming the one source file owning each fact the map leaves to the source. Load this before answering any Trader API TypeScript SDK contract question or writing any SDK code." +description: "Alpaca TypeScript SDK identity and lookup layer (TypeScript/JavaScript only) — install, the single import specifier `alpaca`, the server environments and the base-URL knob, the auth pattern, the SDK map that ships inside the installed package (`sdk-map.md` + `map/operations/`) and how to traverse it, and the file table naming the one source file owning each fact the map leaves to the source. Load this before answering any Alpaca TypeScript SDK contract question or writing any SDK code." --- -# Getting started with the Trader API TypeScript SDK +# Getting started with the Alpaca TypeScript SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Trader API TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-alpaca` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Alpaca TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-alpaca` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated TypeScript SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `typescript-client-initialization`, `typescript-authentication`, `typescript-calling-endpoints`, `typescript-models`, `typescript-error-handling`, `typescript-configuration-resilience` and `typescript-testing`. @@ -17,18 +17,20 @@ Verified against `package.json` and `sdk-map.md` of the generated package at ver | Fact | Value | | --- | --- | -| API | Trader API | -| Package name (what you install, and what you import) | `trader-api` — **not on npm**; built from source (see *Install*) | -| Import specifier | `trader-api` — the package root is the **only** entry; deep imports do not resolve | +| API | Alpaca | +| Package name (what you install, and what you import) | `alpaca` — **not on npm**; built from source (see *Install*) | +| Import specifier | `alpaca` — the package root is the **only** entry; deep imports do not resolve | +| Source repository | https://github.com/context-plugins/alpaca-typescript-sdk (branch `main` — the ref this map documents) | | Version | `2.0.0` (API spec version `2.0.0`) | -| Client class | `TraderApiClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new TraderApiClient(clientOptions: Partial = {})` — **every** field is optional, so `new TraderApiClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `apiKey` · `apiSecret`. `timeout` defaults to `60_000` ms | +| Client class | `AlpacaClient` (`src/client.ts`) — one class, no sync/async split | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new AlpacaClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new AlpacaClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `retry` · `fetch` · `apiKey` · `apiSecret`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **API key (header)** — set `ClientOptions.apiKey`
**API key (header)** — set `ClientOptions.apiSecret` | | Environments | 2 environments (`ServerEnvironment.Paper` *(default)*, `ServerEnvironment.Live`) × 1 server group | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`), defaulting to `https://paper-api.alpaca.markets` | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Base-URL config | `serverOptions.baseUrl` (`src/servers.ts`), defaulting to `https://paper-api.alpaca.markets` | +| Log environment variable | `ALPACA_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Surface | 29 operations · 9 resources · 16 models · 14 open enums · 2 unions · 4 per-operation error subclasses | @@ -37,29 +39,29 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/alpaca-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 32 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 32 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { TraderApiClient, ServerEnvironment, ResponseError, TraderApiError } from "trader-api"; -import type { ClientOptions, Account } from "trader-api"; +import { AlpacaClient, ServerEnvironment, ApiError, AlpacaError } from "alpaca"; +import type { ClientOptions, Account } from "alpaca"; ``` Things the specifier alone will not tell you: -- **Deep imports do not resolve.** The `exports` map exposes `.` and `./package.json` and nothing else, so `trader-api/models/…` fails (`TS2307`) even though the file exists in the shipped `src/`. Every `Source` path on the SDK map is where to **read** a shape, never what to import. -- **From CommonJS**, the typed spelling is `import sdk = require("trader-api")`. A plain `require` destructure runs but yields `any`. +- **Deep imports do not resolve.** The `exports` map exposes `.` and `./package.json` and nothing else, so `alpaca/models/…` fails (`TS2307`) even though the file exists in the shipped `src/`. Every `Source` path on the SDK map is where to **read** a shape, never what to import. +- **From CommonJS**, the typed spelling is `import sdk = require("alpaca")`. A plain `require` destructure runs but yields `any`. - **`instanceof` is reliable within one dialect.** A process that loads both (`import` in one file, `require` in another) gets two independent copies of every error class, and `instanceof` across that boundary is `false` — narrow on `err.kind` / `err.payload.kind` / `err.name` there. Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, every model type) must be imported with `import type`. Under `exactOptionalPropertyTypes`, **omit or spread** an absent optional field rather than assigning `undefined` to it. @@ -70,15 +72,15 @@ Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, | Group | Environment | Base URL | Override at | | --- | --- | --- | --- | -| `default` | `paper` *(default)* | `https://paper-api.alpaca.markets` | `serverOptions.default.paper.baseUrl` | -| `default` | `live` | `https://api.alpaca.markets` | `serverOptions.default.live.baseUrl` | +| `default` | `paper` *(default)* | `https://paper-api.alpaca.markets` | `serverOptions.baseUrl` | +| `default` | `live` | `https://api.alpaca.markets` | `serverOptions.baseUrl` | Consequences to state on every contract sheet that touches configuration: - Constructing the client with no options selects **`ServerEnvironment.Paper`**, silently. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. +- An environment value the SDK does not know throws `ConfigurationError` **from the constructor** — every server group is resolved once, as the client is built — so no operation method throws synchronously. ## Auth pattern (2 schemes) @@ -90,7 +92,7 @@ Authentication is **per operation**: every operation declares the requirement it | `apiSecret` | API key (header) | header `APCA-API-SECRET-KEY: ` | ```ts -const client = new TraderApiClient({ +const client = new AlpacaClient({ apiKey: process.env.API_KEY!, apiSecret: process.env.API_KEY!, }); @@ -102,7 +104,7 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. See `typescript-authentication` for the full picture. @@ -122,7 +124,7 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | `client.calendarApi` | `CalendarApi` | 1 | `getCalendar` | | `client.clockApi` | `ClockApi` | 1 | `getClock` | -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** 2 operations take `RequestParams` instead: `addAssetToWatchlist`, `postWatchlist`. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -130,20 +132,20 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: ```bash -node -e "console.log(require.resolve('trader-api/package.json'))" +node -e "console.log(require.resolve('alpaca/package.json'))" ``` -Failing that it is at `node_modules/trader-api/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/alpaca/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. -Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/trader-api/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. +Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/alpaca/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -151,9 +153,9 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. -The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/trader-api/`: +The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/alpaca/`: | Question | File | | --- | --- | @@ -161,13 +163,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -181,29 +185,37 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `ALPACA_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new TraderApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new AlpacaClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) 2. **Authentication** — load **typescript-authentication** before you set credentials. The 2 schemes are `apiKey`, `apiSecret` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **3 operations resolve to `undefined` (`orders.deleteOrderByOrderId`, `watchlists.deleteWatchlistById`, `watchlists.deleteWatchlistByName`)**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `TraderApiError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `AlpacaError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `ALPACA_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK -Beyond the usual signatures and model members, a contract sheet for the Trader API TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. +Beyond the usual signatures and model members, a contract sheet for the Alpaca TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. 1. **Which host each deployment talks to**, and where that is set. The members are `ServerEnvironment.Paper`, `ServerEnvironment.Live`, defaulting to `ServerEnvironment.Paper` when the field is unset. 2. **3 operations resolve to `undefined` (`orders.deleteOrderByOrderId`, `watchlists.deleteWatchlistById`, `watchlists.deleteWatchlistByName`)** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet — 2 operations take `RequestParams`, not `Request`. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 4 of 29 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 4 of 29 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/alpaca/skills/typescript/typescript-integrate-alpaca/SKILL.md b/plugins/alpaca/skills/typescript/typescript-integrate-alpaca/SKILL.md index 19bdf9548..3128fc1f1 100644 --- a/plugins/alpaca/skills/typescript/typescript-integrate-alpaca/SKILL.md +++ b/plugins/alpaca/skills/typescript/typescript-integrate-alpaca/SKILL.md @@ -1,34 +1,34 @@ --- name: "typescript-integrate-alpaca" -description: "MANDATORY FIRST STEP for Trader API TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Trader API in TypeScript/Node — get account, delete all orders, delete order by order id, get all orders, get order by order id, patch order by order id, or when the Trader API TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `trader-api` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (trader-api-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — constructing the client with no options silently selects ServerEnvironment.Paper, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." +description: "MANDATORY FIRST STEP for Alpaca TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Alpaca in TypeScript/Node — get account, delete all orders, delete order by order id, get all orders, get order by order id, patch order by order id, or when the Alpaca TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `alpaca` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (alpaca-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — constructing the client with no options silently selects ServerEnvironment.Paper, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." --- -# Trader API TypeScript SDK — Integration workflow (lookup layer + contract sheet) +# Alpaca TypeScript SDK — Integration workflow (lookup layer + contract sheet) You do the SDK lookups yourself: `typescript-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source files its table names) before writing code. The `typescript-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. ## Your lookup layer - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. -- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/trader-api/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('trader-api/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/alpaca/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('alpaca/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. -**Scope guard:** the APIMatic-generated Trader API **TypeScript SDK** (package and import specifier `trader-api`, client `TraderApiClient`) in **TypeScript or JavaScript projects only**. Unrelated API, or any language other than TypeScript/JavaScript — do nothing; this skill does not apply. +**Scope guard:** the APIMatic-generated Alpaca **TypeScript SDK** (package and import specifier `alpaca`, client `AlpacaClient`) in **TypeScript or JavaScript projects only**. Unrelated API, or any language other than TypeScript/JavaScript — do nothing; this skill does not apply. ## Workflow -**If the user opens with a reported SDK error, a compile failure, or unexpected Trader API behaviour** (not new feature work), skip the plan-first flow: load `typescript-getting-started` and `typescript-error-handling`, look the failing symbol up on the map page or in the file the table names, and fix from what you find. Otherwise, for implementation work: +**If the user opens with a reported SDK error, a compile failure, or unexpected Alpaca behaviour** (not new feature work), skip the plan-first flow: load `typescript-getting-started` and `typescript-error-handling`, look the failing symbol up on the map page or in the file the table names, and fix from what you find. Otherwise, for implementation work: ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/trader-api-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/alpaca-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, **module format — ESM or CJS, which changes how the SDK is imported and whether `instanceof` on its error classes is trustworthy**, and the runtime: Node, a bundled browser app, or an edge runtime) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- **establish the toolchain before you need it**: the package manager, whether `trader-api` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; +- **establish the toolchain before you need it**: the package manager, whether `alpaca` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; - a baseline run of the project's checks (`tsc --noEmit`, the test runner, the linter — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; - credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since constructing the client with no options silently selects `ServerEnvironment.Paper`, and the other environment is reached only by setting it; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your credential may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; @@ -36,49 +36,125 @@ Your FIRST action is to load `typescript-getting-started` and work through the u Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. -**HARD GATE — no project-file creation or edits until:** `trader-api-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. +**HARD GATE — no project-file creation or edits until:** `alpaca-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. -**What the gate does not cover:** installing `trader-api` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +**What the gate does not cover:** installing `alpaca` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. + +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. Before implementing, check the plan's **Assumptions & Blockers** section: -- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `trader-api-plan.md` in place. +- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `alpaca-plan.md` in place. - Minor assumptions only → proceed. Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. ### Step 2 — Implement from the contract sheet -1. Read `trader-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. +1. Read `alpaca-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. 2. **Decide which environment this deployment talks to, explicitly, before the first call.** Constructing the client with no options selects `ServerEnvironment.Paper` **silently**, so an unset field is indistinguishable from a deliberate choice of the default. Bind it to configuration and fail startup if the (environment, credential) pair is not the one this deployment intends — a credential pointed at the wrong host is not a defect any call site can correct. 3. **Build the client once and hold it.** Resources are memoized lazy getters on it, so rebuilding one per request throws that state away. There is no `close()` — it owns a `fetch`, not a pool. 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from the **3 operations that resolve to `undefined`** (`orders.deleteOrderByOrderId`, `watchlists.deleteWatchlistById`, `watchlists.deleteWatchlistByName`). `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `AlpacaError` family, told apart by `err.kind` — and an unknown `serverEnvironment` throws `ConfigurationError` out of the client constructor, before any call exists. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `ALPACA_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions -A standalone Trader API question with no code change: look it up in `typescript-getting-started` (and the map page or file its table names), then give the grounded answer, citing where it came from. When several questions arrive batched, answer them in one pass. Never answer from memory, even for "easy" questions. +A standalone Alpaca question with no code change: look it up in `typescript-getting-started` (and the map page or file its table names), then give the grounded answer, citing where it came from. When several questions arrive batched, answer them in one pass. Never answer from memory, even for "easy" questions. ## Anti-patterns — never do these - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. -- **Never write any Trader API or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. -- **Don't web-search Trader API topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Trader API docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. +- **Never write any Alpaca or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Don't web-search Alpaca topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Alpaca docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. -- **Don't deep-import from the package** (`trader-api/models/…`). The package root is the only entry and deep specifiers do not resolve; the map's `Source` paths are for reading, not importing. +- **Don't deep-import from the package** (`alpaca/models/…`). The package root is the only entry and deep specifiers do not resolve; the map's `Source` paths are for reading, not importing. - **Don't assume a name is a global.** The package root re-exports every public type, so an unaliased import shadows any same-named global for the rest of the file — check the name against the map before importing it plain. - **Don't vendor the SDK's `src/` into the project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly** to make a lookup possible. Install it properly (see `typescript-getting-started`); a clone-based shortcut breaks every import the moment the clone is deleted, and it does not put the map where the lookups expect it. - **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. diff --git a/plugins/alpaca/skills/typescript/typescript-models/SKILL.md b/plugins/alpaca/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/alpaca/skills/typescript/typescript-models/SKILL.md +++ b/plugins/alpaca/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/alpaca/skills/typescript/typescript-models/reference.md b/plugins/alpaca/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/alpaca/skills/typescript/typescript-models/reference.md +++ b/plugins/alpaca/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/alpaca/skills/typescript/typescript-testing/SKILL.md b/plugins/alpaca/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/alpaca/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/alpaca/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/binance/.claude-plugin/plugin.json b/plugins/binance/.claude-plugin/plugin.json index 9e2e3083c..ea3b55468 100644 --- a/plugins/binance/.claude-plugin/plugin.json +++ b/plugins/binance/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "binance", "displayName": "Binance", - "version": "0.2.2", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Binance Public Spot API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/binance/.cursor-plugin/plugin.json b/plugins/binance/.cursor-plugin/plugin.json index 9e2e3083c..ea3b55468 100644 --- a/plugins/binance/.cursor-plugin/plugin.json +++ b/plugins/binance/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "binance", "displayName": "Binance", - "version": "0.2.2", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Binance Public Spot API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/binance/plugin.json b/plugins/binance/plugin.json index 9e2e3083c..ea3b55468 100644 --- a/plugins/binance/plugin.json +++ b/plugins/binance/plugin.json @@ -1,7 +1,7 @@ { "name": "binance", "displayName": "Binance", - "version": "0.2.2", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Binance Public Spot API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/binance/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/binance/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..05ce631ee 100644 --- a/plugins/binance/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/binance/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,245 +1,73 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Binance .NET SDK in C# — this API authenticates with an API key in a header, and this skill gives the credential each scheme takes, the BinanceClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Binance .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with an API key in a header. APIMatic surfaces each scheme as a **nullable credentials property** on `BinanceClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) - -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). - -## Basic auth - -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` - -Sends `Authorization: Basic base64(username:password)`. - -## Bearer token - -Set the configured token property on the options class to your access-token string: - -```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; -``` - -Sends `Authorization: Bearer ACCESS_TOKEN`. - -## API key (header, query, or cookie) - -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: - -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` - -## OAuth 2.0 — client credentials (machine-to-machine) - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; - -options.{OAuthProperty} = new OAuth2ClientCredentials -{ - ClientId = "...", - ClientSecret = "...", - Scope = "..." // optional -}; -``` - -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. - -## OAuth 2.0 — authorization code (3-legged, with PKCE) - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; - -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials -{ - ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE - RedirectUri = "https://app.example.com/callback", - Scope = "...", // optional - State = "...", // optional CSRF token - Pkce = PkceMethod.S256, // default; RFC 7636 - PromptForAuthorizationCode = async (authorizationUrl, ct) => - { - // Open/redirect the browser to authorizationUrl, then return the - // authorization code your redirect endpoint received. - return await GetCodeFromUserAsync(authorizationUrl, ct); - } -}; -``` - -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. - -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: - -| PKCE | token request built | no secret ⇒ | +| Scheme | Property on `BinanceClientOptions` | Credential | | --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | +| API key — `X-MBX-APIKEY` header | `ApiKeyAuth` | `string` | -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. +**That table is the entire authentication surface of this SDK.** `Binance.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. -## OAuth 2.0 — resource owner password +## API key (header) -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; - -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` +**API Provider Notes:** + +> Binance Public API Key -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. - -### Supplying your own token source - -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. +The key travels in the `X-MBX-APIKEY` request header. The header name is fixed by the generated scheme — you supply only the value: ```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) +options.ApiKeyAuth = "API_KEY"; ``` -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: - -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). +## Operations that need no credentials -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. - -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +Some operations on this API are public: the generated endpoint uses `NoneAuthScheme` and sends no credential, whatever you set on `BinanceClientOptions`. You still construct the same client — there is no separate unauthenticated client — and the credentials you configured are simply not applied to those calls. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `BinanceClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("BinanceClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.ApiKeyAuth)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "BinanceClient:ApiKeyAuth is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"BinanceClient:ApiKeyAuth is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +Check every value the schemes actually need. For this SDK that is: + +| Value to validate | Feeds `BinanceClientOptions` property | +| --- | --- | +| `ApiKeyAuth` | `ApiKeyAuth` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddBinanceClient(options => ...)` callback when registering via DI. `BinanceClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddBinanceClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/binance/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/binance/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/binance/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/binance/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/binance/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/binance/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/binance/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/binance/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/binance/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/binance/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/binance/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/binance/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/binance/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/binance/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/binance/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/binance/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException
` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/binance/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/binance/skills/dotnet/dotnet-getting-started/SKILL.md index 389bb4663..4b9070f1b 100644 --- a/plugins/binance/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/binance/skills/dotnet/dotnet-getting-started/SKILL.md @@ -1,120 +1,123 @@ --- name: "dotnet-getting-started" -description: "Binance Public Spot API .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-binance is the workflow that uses it." +description: "Binance .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-binance is the workflow that uses it." --- -# Getting started with the Binance Public Spot API .NET SDK +# Getting started with the Binance .NET SDK -> **Who this skill is for.** This is the **map layer**: load it to ground every Binance Public Spot API SDK fact. `dotnet-integrate-binance` is the workflow that drives it — you collect the contracts for the work in scope into `binance-public-spot-api-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. +> **Who this skill is for.** This is the **map layer**: load it to ground every Binance SDK fact. `dotnet-integrate-binance` is the workflow that drives it — you collect the contracts for the work in scope into `binance-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated .NET SDK (auth, calling endpoints, models, error handling, retries, testing), see the companion API-agnostic skills: `dotnet-client-initialization`, `dotnet-authentication`, `dotnet-calling-endpoints`, `dotnet-models`, `dotnet-error-handling`, `dotnet-configuration-resilience`, `dotnet-testing`. **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity | | | | --- | --- | -| API | Binance Public Spot API | +| API | Binance | | NuGet package | *not published to NuGet — see *Install* below* | | Source repo | https://github.com/context-plugins/binance-csharp-sdk (branch `main` — the ref this map documents) | -| Root namespace | `BinancePublicSpotApi` (the `using` namespace) | -| Client class | `BinancePublicSpotApiClient` | -| Options class | `BinancePublicSpotApiClientOptions` | +| Root namespace | `Binance` (the `using` namespace) | +| Client class | `BinanceClient` | +| Options class | `BinanceClientOptions` | | Auth | **API key** in the `X-MBX-APIKEY` header — set `ApiKeyAuth` | | Environments | `ServerEnvironment.Production` *(default)* → `https://api.binance.com`
`ServerEnvironment.Environment2` → `https://testnet.binance.vision` | -| Log environment variable | `BINANCEPUBLICSPOTAPICLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Log environment variable | `BINANCECLIENT_LOG` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-binance` makes that binding); the **Integration workflow** below says which governs which step. ## Namespaces (using-directives) -The SDK splits its public types across **separate child namespaces**. C# does **not** import child namespaces transitively, so `using BinancePublicSpotApi.Models;` alone does **not** make enums, union types, or error types visible — you get `CS0103`/`CS0246` ("name/type does not exist") on build. Add a separate `using` for each kind of type you reference: +The SDK splits its public types across **separate child namespaces**. C# does **not** import child namespaces transitively, so `using Binance.Models;` alone does **not** make enums, union types, or error types visible — you get `CS0103`/`CS0246` ("name/type does not exist") on build. Add a separate `using` for each kind of type you reference: | Type | Namespace | | --- | --- | -| `BinancePublicSpotApiClient` | `BinancePublicSpotApi` | -| `BinancePublicSpotApiClientOptions` | `BinancePublicSpotApi` | -| `ServerEnvironment` | `BinancePublicSpotApi.Servers` | +| `BinanceClient` | `Binance` | +| `BinanceClientOptions` | `Binance` | +| `ServerEnvironment` | `Binance.Servers` | Models, enums, union types and the generated error types each sit in their own child namespace. The map's **Namespaces by content type** table maps each source directory to its namespace, so read the namespace off the path the map gives for the type; anything left ambiguous is settled by the `namespace` declaration at the top of the type's own file. ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Binance, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/Binance/Models/`, `src/Binance/Errors/` and `src/Binance/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/Binance/Models/`, enums `src/Binance/Models/Enums/`, typed errors `src/Binance/Errors/`, request records `src/Binance/Requests/`, or `src/Binance/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: -- Collect the contracts for **every** in-scope operation in **one** pass — signatures and error accessors from the map pages, then required fields with wire names and enum values from the declaring files those pages name — into the **contract sheet** in `binance-public-spot-api-plan.md`, then implement from the sheet. Do not re-open a map page or source file per field, and never re-look-up a fact the sheet already carries. +- Collect the contracts for **every** in-scope operation in **one** pass — signatures and error accessors from the map pages, then required fields with wire names and enum values from the declaring files those pages name — into the **contract sheet** in `binance-plan.md`, then implement from the sheet. Do not re-open a map page or source file per field, and never re-look-up a fact the sheet already carries. - When you do open a source file, read it **scoped**: with an offset/limit, or a search for the one symbol — never dump a whole file into the conversation. - Never open the SDK's `api-reference.md` — the map supersedes it. ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/binance-public-spot-api-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/binance-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. -**Clone once, up front**, into a fresh timestamped folder under `/binance-public-spot-api-sdk-src/`, and reuse that folder for the rest of your session: +**Clone once, up front**, into a fresh timestamped folder under `/binance-sdk-src/`, and reuse that folder for the rest of your session: ```bash # Linux/macOS: -dir="${TMPDIR:-/tmp}/binance-public-spot-api-sdk-src/$(date +%Y%m%d-%H%M%S)" +dir="${TMPDIR:-/tmp}/binance-sdk-src/$(date +%Y%m%d-%H%M%S)" git clone --filter=blob:none --branch main https://github.com/context-plugins/binance-csharp-sdk "$dir" # Reuse "$dir" for the rest of your session (it is your clone path). ``` ```powershell # Windows (PowerShell): -$dir = "$env:TEMP\binance-public-spot-api-sdk-src\$(Get-Date -Format yyyyMMdd-HHmmss)" +$dir = "$env:TEMP\binance-sdk-src\$(Get-Date -Format yyyyMMdd-HHmmss)" git clone --filter=blob:none --branch main https://github.com/context-plugins/binance-csharp-sdk $dir # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/binance-public-spot-api-sdk-src/`, **never** in the project repo, and its path never goes into `binance-public-spot-api-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `binance-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `BinancePublicSpotApiClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/Binance/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/Binance/Models/` (+ `src/Binance/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/Binance/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/Binance/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/Binance/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/Binance/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/Binance/Servers/`, `src/Binance/BinanceClient.cs`, `src/Binance/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one -The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Binance Public Spot API may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. +The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Binance may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything -Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's Type sources table names it) lists every field with its `[JsonPropertyName]` wire name. If the scope touches such a field, three generator facts decide your logging posture (all in `dotnet-configuration-resilience`): JSON request bodies are logged **verbatim** when `LogRequestBody` is on, with no redaction; form bodies are masked only by deny-list; and leaving `options.Logging.LoggerFactory` unset arms the `BINANCEPUBLICSPOTAPICLIENT_LOG` environment variable, whose `trace` level forces body logging on with no code change and no deploy. So on any build that can carry such a field: `LogRequestBody` stays off, `LoggerFactory` is set explicitly in production, and your own diagnostics never echo a request body on those paths. +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's Type sources table names it) lists every field with its `[JsonPropertyName]` wire name. If the scope touches such a field, three generator facts decide your logging posture (all in `dotnet-configuration-resilience`): JSON request bodies are logged **verbatim** when `LogRequestBody` is on, with no redaction; form bodies are masked only by deny-list; and leaving `options.Logging.LoggerFactory` unset arms the `BINANCECLIENT_LOG` environment variable, whose `trace` level forces body logging on with no code change and no deploy. So on any build that can carry such a field: `LogRequestBody` stays off, `LoggerFactory` is set explicitly in production, and your own diagnostics never echo a request body on those paths. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. @@ -126,17 +129,17 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/Binance/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step You loaded every companion the sheet named before starting (per `dotnet-integrate-binance`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new BinancePublicSpotApiClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) -2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `BinancePublicSpotApiClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new BinanceClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) +2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `BinanceClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/binance/skills/dotnet/dotnet-integrate-binance/SKILL.md b/plugins/binance/skills/dotnet/dotnet-integrate-binance/SKILL.md index 728dd2d91..69d77042b 100644 --- a/plugins/binance/skills/dotnet/dotnet-integrate-binance/SKILL.md +++ b/plugins/binance/skills/dotnet/dotnet-integrate-binance/SKILL.md @@ -1,35 +1,35 @@ --- name: "dotnet-integrate-binance" -description: "MANDATORY FIRST STEP for Binance Public Spot API .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Binance Public Spot API in C# — AutoInvest, Blvt, C2C, ConvertApi, CopyTrading, CryptoLoans, DualInvestment, Fiat, Futures, FuturesAlgo, GiftCard, IsolatedMarginStream, Margin, MarginStream, Market, Mining, Nft, Pay, PortfolioMargin, Rebate, Savings, SimpleEarn, SpotAlgo, Staking, StreamApi, SubAccountApi, TradeApi, VipLoans, Wallet — or when a Binance Public Spot API .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file binance-public-spot-api-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." +description: "MANDATORY FIRST STEP for Binance .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Binance in C# — AutoInvest, Blvt, C2C, ConvertApi, CopyTrading, CryptoLoans, DualInvestment, Fiat, Futures, FuturesAlgo, GiftCard, IsolatedMarginStream, Margin, MarginStream, Market, Mining, Nft, Pay, PortfolioMargin, Rebate, Savings, SimpleEarn, SpotAlgo, Staking, Stream, SubAccountApi, TradeApi, VipLoans, Wallet — or when a Binance .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file binance-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." --- -# Binance Public Spot API .NET SDK — Workflow (map + skills) +# Binance .NET SDK — Workflow (map + skills) -You are the Binance Public Spot API .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. +You are the Binance .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. -**Scope guard:** the APIMatic-generated Binance Public Spot API **.NET SDK** (root namespace `BinancePublicSpotApi`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. +**Scope guard:** the APIMatic-generated Binance **.NET SDK** (root namespace `Binance`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. ## Grounding rules -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `binance-public-spot-api-plan.md` — the plan must stay portable. +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `binance-plan.md` never carries its path — the plan must stay portable. - **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. - **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. - **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. -- **The sheet records Binance Public Spot API's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. +- **The sheet records Binance's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. - **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. ## Workflow -**If the user opens with a reported SDK error or unexpected Binance Public Spot API behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: +**If the user opens with a reported SDK error or unexpected Binance behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: -### Step 1 — Plan first: write `binance-public-spot-api-plan.md` (always, for any implementation work) +### Step 1 — Plan first: write `binance-plan.md` (always, for any implementation work) -Your FIRST deliverable is `binance-public-spot-api-plan.md` at `/binance-public-spot-api-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. +Your FIRST deliverable is `binance-plan.md` at `/binance-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. #### How to ground (map-first, one pass) 1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. 3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. 4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. 5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. @@ -38,7 +38,7 @@ Your FIRST deliverable is `binance-public-spot-api-plan.md` at ``** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. 6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. 7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. @@ -67,12 +80,46 @@ Never use this prerequisite work as a "head start" on implementation: **creating | 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | | 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | | 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | | 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | | 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | | 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. @@ -84,42 +131,43 @@ These are API-agnostic usage skills; loading them is not the same as reading the Before implementing, check the plan's **Assumptions & Blockers** section: -- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `binance-public-spot-api-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. +- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `binance-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. - Minor assumptions only → proceed. Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. ### Step 3 — Implement from the contract sheet -1. Read `binance-public-spot-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. +1. Read `binance-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. 2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. 3. After every change: `dotnet build`; fix non-SDK errors yourself. -4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `BinancePublicSpotApi.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. +4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `Binance.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. ### Step 4 — Fixing SDK errors (map-first, in place) -A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `BinancePublicSpotApi.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. +A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `Binance.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/Binance/Models/{Type}.cs`, `src/Binance/Models/Enums/{Type}.cs`, `src/Binance/Errors/{Type}.cs`, `src/Binance/Requests/{Type}.cs`, or `src/Binance/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. 3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". 4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). 5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. 6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. -If a fix corrects a row in `binance-public-spot-api-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. +If a fix corrects a row in `binance-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. ### Step 5 — Pure questions -A standalone Binance Public Spot API question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. +A standalone Binance question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. ## Anti-patterns — never do these -- **Never write a Binance Public Spot API/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** +- **Never write a Binance/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** - **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. - **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. - **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search Binance Public Spot API for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `BinancePublicSpotApi` is what the generated source declares, not what public docs describe. +- **Do not web-search Binance for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `Binance` is what the generated source declares, not what public docs describe. - **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. diff --git a/plugins/binance/skills/dotnet/dotnet-models/SKILL.md b/plugins/binance/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/binance/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/binance/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/binance/skills/dotnet/dotnet-models/reference.md b/plugins/binance/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/binance/skills/dotnet/dotnet-models/reference.md +++ b/plugins/binance/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/binance/skills/dotnet/dotnet-testing/SKILL.md b/plugins/binance/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/binance/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/binance/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/binance/skills/python/python-authentication/SKILL.md b/plugins/binance/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/binance/skills/python/python-authentication/SKILL.md +++ b/plugins/binance/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/binance/skills/python/python-calling-endpoints/SKILL.md b/plugins/binance/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/binance/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/binance/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/binance/skills/python/python-client-initialization/SKILL.md b/plugins/binance/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/binance/skills/python/python-client-initialization/SKILL.md +++ b/plugins/binance/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/binance/skills/python/python-configuration-resilience/SKILL.md b/plugins/binance/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/binance/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/binance/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/binance/skills/python/python-error-handling/SKILL.md b/plugins/binance/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/binance/skills/python/python-error-handling/SKILL.md +++ b/plugins/binance/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/binance/skills/python/python-event-streams/SKILL.md b/plugins/binance/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/binance/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/binance/skills/python/python-file-handling/SKILL.md b/plugins/binance/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/binance/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/binance/skills/python/python-getting-started/SKILL.md b/plugins/binance/skills/python/python-getting-started/SKILL.md index 088f9f3dc..dc183cbfc 100644 --- a/plugins/binance/skills/python/python-getting-started/SKILL.md +++ b/plugins/binance/skills/python/python-getting-started/SKILL.md @@ -1,11 +1,11 @@ --- name: "python-getting-started" -description: "Binance Public Spot API Python SDK identity and lookup layer (Python only) — install, import root, base URL/environments, the auth pattern, the SDK map that ships at the SDK root (`sdk-map.md` + `map/operations/`) and how to traverse it, and the module table naming the one file owning each fact the map leaves to the source. Load this before answering any Binance Public Spot API Python SDK contract question or writing any SDK code." +description: "Binance Python SDK identity and lookup layer (Python only) — install, import root, base URL/environments, the auth pattern, the SDK map that ships at the SDK root (`sdk-map.md` + `map/operations/`) and how to traverse it, and the module table naming the one file owning each fact the map leaves to the source. Load this before answering any Binance Python SDK contract question or writing any SDK code." --- -# Getting started with the Binance Public Spot API Python SDK +# Getting started with the Binance Python SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Binance Public Spot API Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-binance` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Binance Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-binance` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated Python SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `python-client-initialization`, `python-authentication`, `python-calling-endpoints`, `python-models`, `python-error-handling`, `python-configuration-resilience` and `python-testing`. @@ -13,24 +13,24 @@ This is the **SDK-specific** entry point. For general patterns that apply to any ## SDK identity -Verified against `binance_public_spot_api/` and `pyproject.toml` of the generated package at version `1.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. +Verified against `binance/` and `pyproject.toml` of the generated package at version `1.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. | Fact | Value | | --- | --- | -| API | Binance Public Spot API | -| Distribution name (what you install) | `binance-public-spot-api` — **not on any package index**; installed from source (see *Install*) | -| Import root (what you import) | `binance_public_spot_api` — not the string you install | +| API | Binance | +| Distribution name (what you install) | `binance` — **not on any package index**; installed from source (see *Install*) | +| Import root (what you import) | `binance` — the same string you install | | Source repository | https://github.com/context-plugins/binance-python-sdk | | Source branch | `main` | | Version | `1.0` | -| Sync client class | `BinancePublicSpotApiClient` (alias `Client`) | -| Async client class | `AsyncBinancePublicSpotApiClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `environment` · `base_url` · `timeout` (default `30.0`) · `api_key_auth`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Sync client class | `BinanceClient` (alias `Client`) | +| Async client class | `AsyncBinanceClient` (alias `AsyncClient`) | +| Client construction | **keyword-only**: `environment` · `base_url` · `timeout` (default `30.0`) · `retry_options` · `api_key_auth`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | **API key** in the `X-MBX-APIKEY` header — set `api_key_auth` | | Environments | 2 environments selected by `environment` (default `"production"`), overridable with `base_url` | -| Base-URL config | `ServerConfig` (`binance_public_spot_api/server/server_config.py`), frozen, `extra="forbid"` | +| Base-URL config | `ServerConfig` (`binance/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | | Surface | 340 operations across 29 controllers · 522 models · 15 unions · 62 enums · 333 per-operation error unions | @@ -39,25 +39,30 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash -pip install "binance-public-spot-api @ git+https://github.com/context-plugins/binance-python-sdk.git@main" +pip install "binance @ git+https://github.com/context-plugins/binance-python-sdk.git@main" +python -c "import binance, pathlib; print(pathlib.Path(binance.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules -Python does not re-export child modules transitively, so `from binance_public_spot_api import models` alone does **not** make enums, error unions, or runtime types reachable. Import each kind of type from the module that owns it. +Python does not re-export child modules transitively, so `from binance import models` alone does **not** make enums, error unions, or runtime types reachable. Import each kind of type from the module that owns it. -`binance_public_spot_api/__init__.py` exports exactly 6 names beside the `binance_public_spot_api.models` subpackage it re-exports: +`binance/__init__.py` exports exactly 6 names beside the `binance.models` subpackage it re-exports: ```python -from binance_public_spot_api import ( - AsyncBinancePublicSpotApiClient, +from binance import ( + AsyncBinanceClient, AsyncClient, - BinancePublicSpotApiClient, + BinanceClient, Client, Environment, ServerConfig, @@ -68,12 +73,12 @@ Everything else comes from its own subpackage, and the split matters because eac | What you need | Where it lives | | --- | --- | -| Domain models, their `…Dict` companions | `binance_public_spot_api.models` | -| Enums (and their open `…OrStr` aliases) | `binance_public_spot_api.models.enums` | -| `ApiError` · `RawError` · `ApiResult` · `RequestOptions` · `HttpClient` · `SdkBaseModel` · `UNSET` · `Optional` | `binance_public_spot_api.core` | -| Per-operation error *unions* | `binance_public_spot_api.errors` (`HrTickerPriceChangeStatistics24ErrorBody`, …) | +| Domain models, their `…Dict` companions | `binance.models` | +| Enums (and their open `…OrStr` aliases) | `binance.models.enums` | +| `ApiError` · `RawError` · `ApiResult` · `RequestOptions` · `HttpClient` · `SdkBaseModel` · `UNSET` · `Optional` | `binance.core` | +| Per-operation error *unions* | `binance.errors` (`HrTickerPriceChangeStatistics24ErrorBody`, …) | -`binance_public_spot_api.core` re-exports its whole public surface (a curated `__all__`), so import from `…core` rather than from the private modules beneath it (`…core.results`, `…core.exceptions`, `…core.auth.schemes`). +`binance.core` re-exports its whole public surface (a curated `__all__`), so import from `…core` rather than from the private modules beneath it (`…core.results`, `…core.exceptions`, `…core.auth.schemes`). ## Environments @@ -96,7 +101,7 @@ Consequences to state on every contract sheet that touches configuration: An API key sent as the `X-MBX-APIKEY` header, exposed as the client's `api_key_auth=` keyword taking a plain string. ```python -from binance_public_spot_api import Client +from binance import Client client = Client(api_key_auth="") ``` @@ -147,24 +152,24 @@ The SDK map's controller table carries the same counts and links to a page per c ## SDK map — look up first, open the module second -The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `binance_public_spot_api/` source directory, and these two entries: +The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `binance/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `binance_public_spot_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `binance/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/binance-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. -Every `Source` path on the map is relative to that SDK root, so `binance_public_spot_api/models/account_profit.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. +Every `Source` path on the map is relative to that SDK root, so `binance/models/account_profit.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. **The map is the locator; the source modules are the shapes.** Read the map first — signatures, routes, parameter roles, return types, error unions, and which module declares a type are all answered there without opening a single `.py` file. Then open the one module the map names for what it deliberately does not carry: a model's members, an enum's values, a field's wire alias. The map says so itself — *"Shapes live only in the source … Never grep for a type."* -**The map carries shapes; what an operation *means* lives elsewhere.** When *what* to pass depends on meaning — which values a field accepts beyond its type, a rule that couples two fields, what a defaulted parameter actually selects — the map will not settle it. Open the operation's docstring in `binance_public_spot_api/apis/.py` (or `client.py` where operations sit on the client) and `api-reference.md` at the SDK root for that operation *before* writing the sheet row, and record what you found there. A value you already "know" for a field the map types as a plain `str` is a lookup, not a recall — the memory ban applies to it. +**The map carries shapes; what an operation *means* lives elsewhere.** When *what* to pass depends on meaning — which values a field accepts beyond its type, a rule that couples two fields, what a defaulted parameter actually selects — the map will not settle it. Open the operation's docstring in `binance/apis/.py` (or `client.py` where operations sit on the client) and `api-reference.md` at the SDK root for that operation *before* writing the sheet row, and record what you found there. A value you already "know" for a field the map types as a plain `str` is a lookup, not a recall — the memory ban applies to it. `sdk-map.md` carries the invariants every operation block assumes, so load it before any `map/operations/` page; the pages are written to be read beside it. @@ -175,10 +180,10 @@ Every `Source` path on the map is relative to that SDK root, so `binance_public_ Read the one module that owns the fact **inside the installed package**. Locate it first: ```bash -python -c "import binance_public_spot_api, pathlib; print(pathlib.Path(binance_public_spot_api.__file__).parent)" +python -c "import binance, pathlib; print(pathlib.Path(binance.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/binance_public_spot_api` on Windows, `.venv/lib/python3.*/site-packages/binance_public_spot_api` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/binance` on Windows, `.venv/lib/python3.*/site-packages/binance` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -186,7 +191,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/bi | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -196,7 +201,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/bi | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | | An operation's error mapper (status → schema) | `errors/_error.py` | @@ -213,13 +218,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The one scheme is `api_key_auth=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`), so an unknown wire value passes through as a plain `str` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -233,8 +238,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 1. `Error` — 333 operations (`auto_invest.change_plan_status` · `auto_invest.get_list_of_plans` · `auto_invest.get_target_asset_roi_data_user_data` · `auto_invest.get_target_asset_list_user_data` · `auto_invest.index_linked_plan_rebalance_details_user_data` · `auto_invest.index_linked_plan_redemption_trade` · `auto_invest.index_linked_plan_redemption_history_user_data` · `auto_invest.investment_plan_adjustment` · `auto_invest.investment_plan_creation_user_data` · `auto_invest.one_time_transaction_trade` · `auto_invest.query_index_details_user_data` · `auto_invest.query_index_linked_plan_position_details_user_data` · `auto_invest.query_one_time_transaction_status_user_data` · `auto_invest.query_all_source_asset_and_target_asset_user_data` · `auto_invest.query_holding_details_of_the_plan` · `auto_invest.query_source_asset_list_user_data` · `auto_invest.query_subscription_transaction_history` · `blvt.blvt_info_market_data` · `blvt.blvt_user_limit_info_user_data` · `blvt.query_subscription_record_user_data` · `blvt.redeem_blvt_user_data` · `blvt.redemption_record_user_data` · `blvt.subscribe_blvt_user_data` · `c2_c.get_c2_c_trade_history_user_data` · `convert.accept_quote_trade` · `convert.cancel_limit_order_user_data` · `convert.get_convert_trade_history_user_data` · `convert.list_all_convert_pairs` · `convert.order_status_user_data` · `convert.place_limit_order_user_data` · `convert.query_limit_open_orders_user_data` · `convert.query_order_quantity_precision_per_asset_user_data` · `convert.send_quote_request_user_data` · `copy_trading.get_futures_lead_trader_status_trade` · `copy_trading.get_futures_lead_trading_symbol_whitelist_user_data` · `crypto_loans.adjust_ltv_flexible_loan_adjust_ltv_trade` · `crypto_loans.adjust_ltv_get_flexible_loan_ltv_adjustment_history_user_data` · `crypto_loans.borrow_flexible_loan_borrow_trade` · `crypto_loans.borrow_get_flexible_loan_borrow_history_user_data` · `crypto_loans.borrow_get_flexible_loan_ongoing_orders_user_data` · `crypto_loans.check_collateral_repay_rate_user_data` · `crypto_loans.crypto_loan_adjust_ltv_trade` · `crypto_loans.crypto_loan_borrow_trade` · `crypto_loans.crypto_loan_customize_margin_call_trade` · `crypto_loans.crypto_loan_repay_trade` · `crypto_loans.get_collateral_assets_data_user_data` · `crypto_loans.get_crypto_loans_borrow_history_user_data` · `crypto_loans.get_crypto_loans_income_history_user_data` · `crypto_loans.get_flexible_loan_assets_data_user_data` · `crypto_loans.get_flexible_loan_collateral_assets_data_user_data` · `crypto_loans.get_loan_ltv_adjustment_history_user_data` · `crypto_loans.get_loan_ongoing_orders_user_data` · `crypto_loans.get_loan_repayment_history_user_data` · `crypto_loans.get_loanable_assets_data_user_data` · `crypto_loans.repay_flexible_loan_repay_trade` · `crypto_loans.repay_get_flexible_loan_repayment_history_user_data` · `dual_investment.change_auto_compound_status_user_data` · `dual_investment.check_dual_investment_accounts_user_data` · `dual_investment.get_dual_investment_positions_user_data` · `dual_investment.get_dual_investment_product_list_user_data` · `dual_investment.subscribe_dual_investment_products_user_data` · `fiat.fiat_deposit_withdraw_history_user_data` · `fiat.fiat_payments_history_user_data` · `futures.get_future_account_transaction_history_list_user_data` · `futures.get_future_tick_level_orderbook_historical_data_download_link_user_data` · `futures.new_future_account_transfer_user_data` · `futures_algo.cancel_algo_order_trade` · `futures_algo.query_current_algo_open_orders_user_data` · `futures_algo.query_historical_algo_orders_user_data` · `futures_algo.query_sub_orders_user_data` · `futures_algo.time_weighted_average_price_twap_new_order_trade` · `futures_algo.volume_participation_vp_new_order_trade` · `gift_card.buy_a_binance_code_trade` · `gift_card.create_a_binance_code_user_data` · `gift_card.fetch_rsa_public_key_user_data` · `gift_card.fetch_token_limit_user_data` · `gift_card.redeem_a_binance_code_user_data` · `gift_card.verify_a_binance_code_user_data` · `isolated_margin_stream.close_a_listen_key_user_stream_3` · `isolated_margin_stream.ping_keep_alive_a_listen_key_user_stream` · `margin.adjust_cross_margin_max_leverage_user_data` · `margin.cross_margin_collateral_ratio_market_data` · `margin.disable_isolated_margin_account_trade` · `margin.enable_isolated_margin_account_trade` · `margin.get_all_cross_margin_pairs_market_data` · `margin.get_all_isolated_margin_symbol_user_data` · `margin.get_all_margin_assets_market_data` · `margin.get_bnb_burn_status_user_data` · `margin.get_cross_margin_transfer_history_user_data` · `margin.get_force_liquidation_record_user_data` · `margin.get_interest_history_user_data` · `margin.get_small_liability_exchange_coin_list_user_data` · `margin.get_small_liability_exchange_history_user_data` · `margin.get_summary_of_margin_account_user_data` · `margin.get_a_future_hourly_interest_rate_user_data` · `margin.get_cross_or_isolated_margin_capital_flow_user_data` · `margin.get_tokens_or_symbols_delist_schedule_for_cross_margin_and_isolated_margin_market_data` · `margin.margin_account_cancel_oco_trade` · `margin.margin_account_cancel_order_trade` · `margin.margin_account_cancel_all_open_orders_on_a_symbol_trade` · `margin.margin_account_new_oco_trade` · `margin.margin_account_new_oto_trade` · `margin.margin_account_new_otoco_trade` · `margin.margin_account_new_order_trade` · `margin.margin_interest_rate_history_user_data` · `margin.margin_account_borrow_repay_margin` · `margin.margin_manual_liquidation_margin` · `margin.query_cross_margin_account_details_user_data` · `margin.query_cross_margin_fee_data_user_data` · `margin.query_current_margin_order_count_usage_trade` · `margin.query_enabled_isolated_margin_account_limit_user_data` · `margin.query_isolated_margin_account_info_user_data` · `margin.query_isolated_margin_fee_data_user_data` · `margin.query_isolated_margin_tier_data_user_data` · `margin.query_liability_coin_leverage_bracket_in_cross_margin_pro_mode_market_data` · `margin.query_margin_account_s_all_orders_user_data` · `margin.query_margin_account_s_oco_user_data` · `margin.query_margin_account_s_open_oco_user_data` · `margin.query_margin_account_s_open_orders_user_data` · `margin.query_margin_account_s_order_user_data` · `margin.query_margin_account_s_trade_list_user_data` · `margin.query_margin_account_s_all_oco_user_data` · `margin.query_margin_available_inventory_user_data` · `margin.query_margin_price_index_market_data` · `margin.query_max_borrow_user_data` · `margin.query_max_transfer_out_amount_user_data` · `margin.query_borrow_repay_records_in_margin_account_user_data` · `margin.toggle_bnb_burn_on_spot_trade_and_margin_interest_user_data` · `margin_stream.close_a_listen_key_user_stream_2` · `margin_stream.ping_keep_alive_a_listen_key_user_stream_2` · `market.hr_ticker_price_change_statistics24` · `market.compressed_aggregate_trades_list` · `market.current_average_price` · `market.exchange_information` · `market.kline_candlestick_data` · `market.order_book` · `market.recent_trades_list` · `market.rolling_window_price_change_statistics` · `market.symbol_order_book_ticker` · `market.symbol_price_ticker` · `market.trading_day_ticker` · `market.ui_klines` · `mining.account_list_user_data` · `mining.acquiring_algorithm_market_data` · `mining.acquiring_coin_name_market_data` · `mining.cancel_hashrate_resale_configuration_user_data` · `mining.earnings_list_user_data` · `mining.extra_bonus_list_user_data` · `mining.hashrate_resale_details_user_data` · `mining.hashrate_resale_list_user_data` · `mining.hashrate_resale_request_user_data` · `mining.mining_account_earning_user_data` · `mining.request_for_detail_miner_list_user_data` · `mining.request_for_miner_list_user_data` · `mining.statistic_list_user_data` · `nft.get_nft_asset_user_data` · `nft.get_nft_deposit_history_user_data` · `nft.get_nft_transaction_history_user_data` · `nft.get_nft_withdraw_history_user_data` · `pay.get_pay_trade_history_user_data` · `portfolio_margin.bnb_transfer_user_data` · `portfolio_margin.change_auto_repay_futures_status_user_data` · `portfolio_margin.fund_auto_collection_user_data` · `portfolio_margin.fund_collection_by_asset_user_data` · `portfolio_margin.get_auto_repay_futures_status_user_data` · `portfolio_margin.get_portfolio_margin_asset_leverage_user_data` · `portfolio_margin.portfolio_margin_account_user_data` · `portfolio_margin.portfolio_margin_bankruptcy_loan_amount_user_data` · `portfolio_margin.portfolio_margin_bankruptcy_loan_repay_user_data` · `portfolio_margin.portfolio_margin_collateral_rate_market_data` · `portfolio_margin.portfolio_margin_pro_tiered_collateral_rate_user_data` · `portfolio_margin.query_classic_portfolio_margin_negative_balance_interest_history_user_data` · `portfolio_margin.query_portfolio_margin_asset_index_price_market_data` · `portfolio_margin.repay_futures_negative_balance_user_data` · `rebate.get_spot_rebate_history_records_user_data` · `savings.change_fixed_activity_position_to_daily_position_user_data` · `savings.get_fixed_activity_project_list_user_data` · `savings.get_fixed_activity_project_position_user_data` · `savings.purchase_fixed_activity_project_user_data` · `simple_earn.get_collateral_record_user_data` · `simple_earn.get_flexible_personal_left_quota_user_data` · `simple_earn.get_flexible_product_position_user_data` · `simple_earn.get_flexible_redemption_record_user_data` · `simple_earn.get_flexible_rewards_history_user_data` · `simple_earn.get_flexible_subscription_preview_user_data` · `simple_earn.get_flexible_subscription_record_user_data` · `simple_earn.get_locked_personal_left_quota_user_data` · `simple_earn.get_locked_product_position_user_data` · `simple_earn.get_locked_redemption_record_user_data` · `simple_earn.get_locked_rewards_history_user_data` · `simple_earn.get_locked_subscription_preview_user_data` · `simple_earn.get_locked_subscription_record_user_data` · `simple_earn.get_rate_history_user_data` · `simple_earn.get_simple_earn_flexible_product_list_user_data` · `simple_earn.get_simple_earn_locked_product_list_user_data` · `simple_earn.redeem_flexible_product_trade` · `simple_earn.redeem_locked_product_trade` · `simple_earn.set_flexible_auto_subscribe_user_data` · `simple_earn.set_locked_auto_subscribe_user_data` · `simple_earn.set_locked_product_redeem_option_user_data` · `simple_earn.simple_account_user_data` · `simple_earn.subscribe_flexible_product_trade` · `simple_earn.subscribe_locked_product_trade` · `spot_algo.cancel_algo_order` · `spot_algo.query_current_algo_open_orders` · `spot_algo.query_historical_algo_orders` · `spot_algo.query_sub_orders` · `spot_algo.time_weighted_average_price_twap_new_order` · `staking.eth_staking_account_v2_user_data` · `staking.get_beth_rewards_distribution_history_user_data` · `staking.get_eth_redemption_history_user_data` · `staking.get_eth_staking_history_user_data` · `staking.get_wbeth_rate_history_user_data` · `staking.get_wbeth_rewards_history_user_data` · `staking.get_wbeth_unwrap_history_user_data` · `staking.get_wbeth_wrap_history_user_data` · `staking.get_current_eth_staking_quota_user_data` · `staking.redeem_eth_trade` · `staking.subscribe_eth_staking_v2_trade` · `staking.wrap_beth_trade` · `stream.close_a_listen_key_user_stream` · `stream.ping_keep_alive_a_listen_key_user_stream` · `sub_account_api.create_a_virtual_sub_account_for_master_account` · `sub_account_api.delete_ip_list_for_a_sub_account_api_key_for_master_account` · `sub_account_api.deposit_assets_into_the_managed_sub_account_for_investor_master_account` · `sub_account_api.detail_on_sub_account_s_futures_account_for_master_account` · `sub_account_api.detail_on_sub_account_s_futures_account_v2_for_master_account` · `sub_account_api.detail_on_sub_account_s_margin_account_for_master_account` · `sub_account_api.enable_futures_for_sub_account_for_master_account` · `sub_account_api.enable_leverage_token_for_sub_account_for_master_account` · `sub_account_api.enable_margin_for_sub_account_for_master_account` · `sub_account_api.enable_options_for_sub_account_for_master_account_user_data` · `sub_account_api.futures_position_risk_of_sub_account_for_master_account` · `sub_account_api.futures_position_risk_of_sub_account_v2_for_master_account` · `sub_account_api.get_ip_restriction_for_a_sub_account_api_key_for_master_account` · `sub_account_api.get_managed_sub_account_deposit_address_for_investor_master_account` · `sub_account_api.managed_sub_account_asset_details_for_investor_master_account` · `sub_account_api.managed_sub_account_snapshot_for_investor_master_account` · `sub_account_api.margin_transfer_for_sub_account_for_master_account` · `sub_account_api.query_managed_sub_account_transfer_log_for_investor_master_account` · `sub_account_api.query_managed_sub_account_transfer_log_for_trading_team_master_account` · `sub_account_api.query_managed_sub_account_transfer_log_for_trading_team_sub_account_user_data` · `sub_account_api.query_managed_sub_account_futures_asset_details_for_investor_master_account` · `sub_account_api.query_managed_sub_account_list_for_investor` · `sub_account_api.query_managed_sub_account_margin_asset_details_for_investor_master_account` · `sub_account_api.query_sub_account_assets_for_master_account` · `sub_account_api.query_sub_account_list_for_master_account` · `sub_account_api.query_sub_account_transaction_statistics_for_master_account` · `sub_account_api.sub_account_assets_for_master_account` · `sub_account_api.sub_account_deposit_history_for_master_account` · `sub_account_api.sub_account_futures_asset_transfer_for_master_account` · `sub_account_api.sub_account_futures_asset_transfer_history_for_master_account` · `sub_account_api.sub_account_spot_asset_transfer_history_for_master_account` · `sub_account_api.sub_account_spot_assets_summary_for_master_account` · `sub_account_api.sub_account_spot_assets_summary_for_master_account_2` · `sub_account_api.sub_account_transfer_history_for_sub_account` · `sub_account_api.sub_account_s_status_on_margin_futures_for_master_account` · `sub_account_api.summary_of_sub_account_s_futures_account_for_master_account` · `sub_account_api.summary_of_sub_account_s_futures_account_v2_for_master_account` · `sub_account_api.summary_of_sub_account_s_margin_account_for_master_account` · `sub_account_api.transfer_for_sub_account_for_master_account` · `sub_account_api.transfer_to_master_for_sub_account` · `sub_account_api.transfer_to_sub_account_of_same_master_for_sub_account` · `sub_account_api.universal_transfer_for_master_account` · `sub_account_api.universal_transfer_history_for_master_account` · `sub_account_api.update_ip_restriction_for_sub_account_api_key_for_master_account` · `sub_account_api.withdrawl_assets_from_the_managed_sub_account_for_investor_master_account` · `trade_api.account_information_user_data` · `trade_api.account_trade_list_user_data` · `trade_api.all_orders_user_data` · `trade_api.cancel_oco_trade` · `trade_api.cancel_order_trade` · `trade_api.cancel_all_open_orders_on_a_symbol_trade` · `trade_api.cancel_an_existing_order_and_send_a_new_order_trade` · `trade_api.current_open_orders_user_data` · `trade_api.new_order_trade` · `trade_api.new_order_list_oto_trade` · `trade_api.new_order_list_otoco_trade` · `trade_api.new_order_list_oco_trade` · `trade_api.new_order_using_sor_trade` · `trade_api.query_allocations_user_data` · `trade_api.query_commission_rates_user_data` · `trade_api.query_current_order_count_usage_trade` · `trade_api.query_oco_user_data` · `trade_api.query_open_oco_user_data` · `trade_api.query_order_user_data` · `trade_api.query_prevented_matches` · `trade_api.query_all_oco_user_data` · `trade_api.test_new_order_trade` · `trade_api.test_new_order_using_sor_trade` · `vip_loans.check_locked_value_of_vip_collateral_account_user_data` · `vip_loans.get_borrow_interest_rate_user_data` · `vip_loans.get_collateral_asset_data_user_data` · `vip_loans.get_loanable_assets_data` · `vip_loans.get_vip_loan_ongoing_orders_user_data` · `vip_loans.get_vip_loan_repayment_history_user_data` · `vip_loans.query_application_status_user_data` · `vip_loans.vip_loan_borrow` · `vip_loans.vip_loan_renew` · `vip_loans.vip_loan_repay_trade` · `wallet.account_api_trading_status_user_data` · `wallet.account_status_user_data` · `wallet.account_info_user_data` · `wallet.all_coins_information_user_data` · `wallet.asset_detail_user_data` · `wallet.asset_dividend_record_user_data` · `wallet.convert_transfer_user_data` · `wallet.daily_account_snapshot_user_data` · `wallet.deposit_address_supporting_network_user_data` · `wallet.deposit_history_supporting_network_user_data` · `wallet.disable_fast_withdraw_switch_user_data` · `wallet.dust_transfer_user_data` · `wallet.dust_log_user_data` · `wallet.enable_fast_withdraw_switch_user_data` · `wallet.fetch_deposit_address_list_with_network_user_data` · `wallet.fetch_withdraw_address_list_user_data` · `wallet.funding_wallet_user_data` · `wallet.get_api_key_permission_user_data` · `wallet.get_assets_that_can_be_converted_into_bnb_user_data` · `wallet.get_cloud_mining_payment_and_refund_history_user_data` · `wallet.get_symbols_delist_schedule_for_spot_market_data` · `wallet.one_click_arrival_deposit_apply_user_data` · `wallet.query_convert_transfer_user_data` · `wallet.query_user_delegation_history_for_master_account_user_data` · `wallet.query_user_universal_transfer_history_user_data` · `wallet.query_user_wallet_balance_user_data` · `wallet.query_auto_converting_stable_coins_user_data` · `wallet.switch_on_off_busd_and_stable_coins_conversion_user_data_user_data` · `wallet.trade_fee_user_data` · `wallet.user_asset_user_data` · `wallet.user_universal_transfer_user_data` · `wallet.withdraw_user_data` · `wallet.withdraw_history_supporting_network_user_data`); distinguishing members `code` · `msg` 2. *(none)* — `isolated_margin_stream.generate_a_listen_key_user_stream` · `margin_stream.create_a_listen_key_user_stream_2` · `market.check_server_time` · `market.old_trade_lookup` · `market.test_connectivity` · `stream.create_a_listen_key_user_stream` · `wallet.system_status_system`: no typed arm, so `.error` is always `RawError` 3. So `isinstance(e.error, Error)` matches only 333 of 340 operations. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 306 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"` of the 2 declared. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 306 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"` of the 2 declared. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/binance/skills/python/python-integrate-binance/SKILL.md b/plugins/binance/skills/python/python-integrate-binance/SKILL.md index e28012146..5f4286915 100644 --- a/plugins/binance/skills/python/python-integrate-binance/SKILL.md +++ b/plugins/binance/skills/python/python-integrate-binance/SKILL.md @@ -1,9 +1,9 @@ --- name: "python-integrate-binance" -description: "MANDATORY FIRST STEP for Binance Public Spot API Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Binance Public Spot API in Python — change plan status, query plan lists, roi return list for target asset, weight(ip): 1, to redeem index-linked plan holdings, query source asset to be used for investment, post an investment plan creation, one time transaction — or when a Binance Public Spot API Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." +description: "MANDATORY FIRST STEP for Binance Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Binance in Python — change plan status, query plan lists, roi return list for target asset, weight(ip): 1, to redeem index-linked plan holdings, query source asset to be used for investment, post an investment plan creation, one time transaction — or when a Binance Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." --- -# Binance Public Spot API Python SDK — Integration workflow (lookup layer + contract sheet) +# Binance Python SDK — Integration workflow (lookup layer + contract sheet) You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. @@ -11,24 +11,24 @@ You do the SDK lookups yourself: `python-getting-started` is your **lookup layer - **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import binance_public_spot_api, pathlib; print(pathlib.Path(binance_public_spot_api.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import binance, pathlib; print(pathlib.Path(binance.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) - **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. -**Scope guard:** the APIMatic-generated Binance Public Spot API **Python SDK** (import root `binance_public_spot_api`, distribution `binance-public-spot-api`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. +**Scope guard:** the APIMatic-generated Binance **Python SDK** (import root `binance`, distribution `binance`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. ## Workflow -**If the user opens with a reported SDK error or unexpected Binance Public Spot API behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: +**If the user opens with a reported SDK error or unexpected Binance behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/binance-public-spot-api-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/binance-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `binance-public-spot-api` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; +- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `binance` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; - a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; - credentials/environment verification (per the task's secret-handling rules) — including which host `environment` will select, since omitting it is silent; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; @@ -36,9 +36,24 @@ Your FIRST action is to load `python-getting-started` and work through the user' Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. -**HARD GATE — no project-file creation or edits until:** `binance-public-spot-api-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. +**HARD GATE — no project-file creation or edits until:** `binance-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. -**What the gate does not cover:** creating or activating a virtual environment, installing `binance-public-spot-api` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. +**What the gate does not cover:** creating or activating a virtual environment, installing `binance` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. ### Step 1c — Required reading (do this before you write any code) @@ -50,30 +65,31 @@ These are API-agnostic usage skills; loading them is not a substitute for the lo Before implementing, check the plan's **Assumptions & Blockers** section: -- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `binance-public-spot-api-plan.md` in place. +- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `binance-plan.md` in place. - Minor assumptions only → proceed. Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. ### Step 2 — Implement from the contract sheet -1. Read `binance-public-spot-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. +1. Read `binance-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. 2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). 3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. 4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `binance_public_spot_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `binance.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. ### Step 3 — Answering pure questions -A standalone Binance Public Spot API question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. +A standalone Binance question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. ## Anti-patterns — never do these - **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. -- **Never write a Binance Public Spot API/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Don't web-search Binance Public Spot API topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Binance Public Spot API docs describe the REST API, not this SDK's generated surface, and the two disagree on names. +- **Never write a Binance/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** +- **Don't web-search Binance topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Binance docs describe the REST API, not this SDK's generated surface, and the two disagree on names. - **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. - **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. diff --git a/plugins/binance/skills/python/python-models/SKILL.md b/plugins/binance/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/binance/skills/python/python-models/SKILL.md +++ b/plugins/binance/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/binance/skills/python/python-models/reference.md b/plugins/binance/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/binance/skills/python/python-models/reference.md +++ b/plugins/binance/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/binance/skills/python/python-testing/SKILL.md b/plugins/binance/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/binance/skills/python/python-testing/SKILL.md +++ b/plugins/binance/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/binance/skills/typescript/typescript-authentication/SKILL.md b/plugins/binance/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/binance/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/binance/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/binance/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/binance/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/binance/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/binance/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/binance/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/binance/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/binance/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/binance/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/binance/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/binance/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/binance/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/binance/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/binance/skills/typescript/typescript-error-handling/SKILL.md b/plugins/binance/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/binance/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/binance/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/binance/skills/typescript/typescript-getting-started/SKILL.md b/plugins/binance/skills/typescript/typescript-getting-started/SKILL.md index 5f46ef224..6fa330c14 100644 --- a/plugins/binance/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/binance/skills/typescript/typescript-getting-started/SKILL.md @@ -1,11 +1,11 @@ --- name: "typescript-getting-started" -description: "Binance Public Spot API TypeScript SDK identity and lookup layer (TypeScript/JavaScript only) — install, the single import specifier `binance-public-spot-api`, the server environments and the base-URL knob, the auth pattern, the SDK map that ships inside the installed package (`sdk-map.md` + `map/operations/`) and how to traverse it, and the file table naming the one source file owning each fact the map leaves to the source. Load this before answering any Binance Public Spot API TypeScript SDK contract question or writing any SDK code." +description: "Binance TypeScript SDK identity and lookup layer (TypeScript/JavaScript only) — install, the single import specifier `binance`, the server environments and the base-URL knob, the auth pattern, the SDK map that ships inside the installed package (`sdk-map.md` + `map/operations/`) and how to traverse it, and the file table naming the one source file owning each fact the map leaves to the source. Load this before answering any Binance TypeScript SDK contract question or writing any SDK code." --- -# Getting started with the Binance Public Spot API TypeScript SDK +# Getting started with the Binance TypeScript SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Binance Public Spot API TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-binance` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Binance TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-binance` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated TypeScript SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `typescript-client-initialization`, `typescript-authentication`, `typescript-calling-endpoints`, `typescript-models`, `typescript-error-handling`, `typescript-configuration-resilience` and `typescript-testing`. @@ -13,22 +13,24 @@ This is the **SDK-specific** entry point. For general patterns that apply to any ## SDK identity -Verified against `package.json` and `sdk-map.md` of the generated package at version `1.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. +Verified against `package.json` and `sdk-map.md` of the generated package at version `1.0.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. | Fact | Value | | --- | --- | -| API | Binance Public Spot API | -| Package name (what you install, and what you import) | `binance-public-spot-api` — **not on npm**; built from source (see *Install*) | -| Import specifier | `binance-public-spot-api` — the package root is the **only** entry; deep imports do not resolve | -| Version | `1.0` (API spec version `1.0`) | -| Client class | `BinancePublicSpotApiClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new BinancePublicSpotApiClient(clientOptions: Partial = {})` — **every** field is optional, so `new BinancePublicSpotApiClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `apiKeyAuth`. `timeout` defaults to `60_000` ms | +| API | Binance | +| Package name (what you install, and what you import) | `binance` — **not on npm**; built from source (see *Install*) | +| Import specifier | `binance` — the package root is the **only** entry; deep imports do not resolve | +| Source repository | https://github.com/context-plugins/binance-typescript-sdk (branch `main` — the ref this map documents) | +| Version | `1.0.0` (API spec version `1.0`) | +| Client class | `BinanceClient` (`src/client.ts`) — one class, no sync/async split | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new BinanceClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new BinanceClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `retry` · `fetch` · `apiKeyAuth`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **API key (header)** — set `ClientOptions.apiKeyAuth` | | Environments | 2 environments (`ServerEnvironment.Production` *(default)*, `ServerEnvironment.Environment2`) × 1 server group | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`), defaulting to `https://api.binance.com` | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Base-URL config | `serverOptions.baseUrl` (`src/servers.ts`), defaulting to `https://api.binance.com` | +| Log environment variable | `BINANCE_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Surface | 340 operations · 29 resources · 522 models · 62 open enums · 15 unions · 333 per-operation error subclasses | @@ -37,30 +39,30 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/binance-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 599 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 599 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { BinancePublicSpotApiClient, ServerEnvironment, ResponseError, BinancePublicSpotApiError } from "binance-public-spot-api"; -import type { ClientOptions, AccountProfit } from "binance-public-spot-api"; +import { BinanceClient, ServerEnvironment, ApiError, BinanceError } from "binance"; +import type { ClientOptions, AccountProfit } from "binance"; ``` Things the specifier alone will not tell you: -- **Deep imports do not resolve.** The `exports` map exposes `.` and `./package.json` and nothing else, so `binance-public-spot-api/models/…` fails (`TS2307`) even though the file exists in the shipped `src/`. Every `Source` path on the SDK map is where to **read** a shape, never what to import. -- **⚠ The SDK exports a model type literally named `Error`** (`src/models/error.ts`, schema `errorSchema`). Import it unaliased and it **shadows the global `Error`** for the rest of the file. Alias it: `import type { Error as SdkError } from "binance-public-spot-api"`. -- **From CommonJS**, the typed spelling is `import sdk = require("binance-public-spot-api")`. A plain `require` destructure runs but yields `any`. +- **Deep imports do not resolve.** The `exports` map exposes `.` and `./package.json` and nothing else, so `binance/models/…` fails (`TS2307`) even though the file exists in the shipped `src/`. Every `Source` path on the SDK map is where to **read** a shape, never what to import. +- **⚠ The SDK exports a model type literally named `Error`** (`src/models/error.ts`, schema `errorSchema`). Import it unaliased and it **shadows the global `Error`** for the rest of the file. Alias it: `import type { Error as SdkError } from "binance"`. +- **From CommonJS**, the typed spelling is `import sdk = require("binance")`. A plain `require` destructure runs but yields `any`. - **`instanceof` is reliable within one dialect.** A process that loads both (`import` in one file, `require` in another) gets two independent copies of every error class, and `instanceof` across that boundary is `false` — narrow on `err.kind` / `err.payload.kind` / `err.name` there. Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, every model type) must be imported with `import type`. Under `exactOptionalPropertyTypes`, **omit or spread** an absent optional field rather than assigning `undefined` to it. @@ -71,15 +73,15 @@ Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, | Group | Environment | Base URL | Override at | | --- | --- | --- | --- | -| `default` | `production` *(default)* | `https://api.binance.com` | `serverOptions.default.production.baseUrl` | -| `default` | `environment2` | `https://testnet.binance.vision` | `serverOptions.default.environment2.baseUrl` | +| `default` | `production` *(default)* | `https://api.binance.com` | `serverOptions.baseUrl` | +| `default` | `environment2` | `https://testnet.binance.vision` | `serverOptions.baseUrl` | Consequences to state on every contract sheet that touches configuration: - Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. +- An environment value the SDK does not know throws `ConfigurationError` **from the constructor** — every server group is resolved once, as the client is built — so no operation method throws synchronously. ## Auth pattern (1 scheme) @@ -90,7 +92,7 @@ Authentication is **per operation**: every operation declares the requirement it | `apiKeyAuth` | API key (header) | header `X-MBX-APIKEY: ` | ```ts -const client = new BinancePublicSpotApiClient({ +const client = new BinanceClient({ apiKeyAuth: process.env.API_KEY!, }); ``` @@ -101,7 +103,7 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. See `typescript-authentication` for the full picture. @@ -141,7 +143,7 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | `client.staking` | `Staking` | 12 | `ethStakingAccountV2UserData` · `getBethRewardsDistributionHistoryUserData` · `getEthRedemptionHistoryUserData` · `getEthStakingHistoryUserData` · `getWbethRateHistoryUserData` · `getWbethRewardsHistoryUserData` · `getWbethUnwrapHistoryUserData` · `getWbethWrapHistoryUserData` · `getCurrentEthStakingQuotaUserData` · `redeemEthTrade` · `subscribeEthStakingV2Trade` · `wrapBethTrade` | | `client.dualInvestment` | `DualInvestment` | 5 | `changeAutoCompoundStatusUserData` · `checkDualInvestmentAccountsUserData` · `getDualInvestmentPositionsUserData` · `getDualInvestmentProductListUserData` · `subscribeDualInvestmentProductsUserData` | -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** A name that collides inside its namespace is promoted to `RequestParams` instead — no operation in this SDK takes that spelling at this version. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -149,20 +151,20 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: ```bash -node -e "console.log(require.resolve('binance-public-spot-api/package.json'))" +node -e "console.log(require.resolve('binance/package.json'))" ``` -Failing that it is at `node_modules/binance-public-spot-api/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/binance/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. -Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/binance-public-spot-api/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. +Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/binance/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -170,9 +172,9 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. -The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/binance-public-spot-api/`: +The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/binance/`: | Question | File | | --- | --- | @@ -180,13 +182,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -200,30 +204,38 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `BINANCE_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new BinancePublicSpotApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new BinanceClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) 2. **Authentication** — load **typescript-authentication** before you set credentials. The scheme is `apiKeyAuth` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **no operation resolves to `undefined` — every one of them carries a body**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `BinancePublicSpotApiError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `BinanceError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `BINANCE_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK -Beyond the usual signatures and model members, a contract sheet for the Binance Public Spot API TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. +Beyond the usual signatures and model members, a contract sheet for the Binance TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. 1. **Which host each deployment talks to**, and where that is set. The members are `ServerEnvironment.Production`, `ServerEnvironment.Environment2`, defaulting to `ServerEnvironment.Production` when the field is unset. 2. **no operation resolves to `undefined` — every one of them carries a body** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 333 of 340 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. **That `Error` imported from this package is a model type, not the global of that name** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `BinancePublicSpotApiError` for the same reason. -10. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 333 of 340 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. **That `Error` imported from this package is a model type, not the global of that name** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `BinanceError` for the same reason. +12. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/binance/skills/typescript/typescript-integrate-binance/SKILL.md b/plugins/binance/skills/typescript/typescript-integrate-binance/SKILL.md index a69d5dd1a..40a5ecb1f 100644 --- a/plugins/binance/skills/typescript/typescript-integrate-binance/SKILL.md +++ b/plugins/binance/skills/typescript/typescript-integrate-binance/SKILL.md @@ -1,34 +1,34 @@ --- name: "typescript-integrate-binance" -description: "MANDATORY FIRST STEP for Binance Public Spot API TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Binance Public Spot API in TypeScript/Node — hr ticker price change statistics24, check server time, compressed aggregate trades list, current average price, exchange information, kline candlestick data, or when the Binance Public Spot API TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `binance-public-spot-api` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (binance-public-spot-api-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — constructing the client with no options silently selects ServerEnvironment.Production, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." +description: "MANDATORY FIRST STEP for Binance TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Binance in TypeScript/Node — hr ticker price change statistics24, check server time, compressed aggregate trades list, current average price, exchange information, kline candlestick data, or when the Binance TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `binance` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (binance-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — constructing the client with no options silently selects ServerEnvironment.Production, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." --- -# Binance Public Spot API TypeScript SDK — Integration workflow (lookup layer + contract sheet) +# Binance TypeScript SDK — Integration workflow (lookup layer + contract sheet) You do the SDK lookups yourself: `typescript-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source files its table names) before writing code. The `typescript-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. ## Your lookup layer - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. -- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/binance-public-spot-api/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('binance-public-spot-api/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/binance/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('binance/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. -**Scope guard:** the APIMatic-generated Binance Public Spot API **TypeScript SDK** (package and import specifier `binance-public-spot-api`, client `BinancePublicSpotApiClient`) in **TypeScript or JavaScript projects only**. Unrelated API, or any language other than TypeScript/JavaScript — do nothing; this skill does not apply. +**Scope guard:** the APIMatic-generated Binance **TypeScript SDK** (package and import specifier `binance`, client `BinanceClient`) in **TypeScript or JavaScript projects only**. Unrelated API, or any language other than TypeScript/JavaScript — do nothing; this skill does not apply. ## Workflow -**If the user opens with a reported SDK error, a compile failure, or unexpected Binance Public Spot API behaviour** (not new feature work), skip the plan-first flow: load `typescript-getting-started` and `typescript-error-handling`, look the failing symbol up on the map page or in the file the table names, and fix from what you find. Otherwise, for implementation work: +**If the user opens with a reported SDK error, a compile failure, or unexpected Binance behaviour** (not new feature work), skip the plan-first flow: load `typescript-getting-started` and `typescript-error-handling`, look the failing symbol up on the map page or in the file the table names, and fix from what you find. Otherwise, for implementation work: ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/binance-public-spot-api-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/binance-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, **module format — ESM or CJS, which changes how the SDK is imported and whether `instanceof` on its error classes is trustworthy**, and the runtime: Node, a bundled browser app, or an edge runtime) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- **establish the toolchain before you need it**: the package manager, whether `binance-public-spot-api` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; +- **establish the toolchain before you need it**: the package manager, whether `binance` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; - a baseline run of the project's checks (`tsc --noEmit`, the test runner, the linter — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; - credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since constructing the client with no options silently selects `ServerEnvironment.Production`, and the other environment is reached only by setting it; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your credential may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; @@ -36,50 +36,126 @@ Your FIRST action is to load `typescript-getting-started` and work through the u Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. -**HARD GATE — no project-file creation or edits until:** `binance-public-spot-api-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. +**HARD GATE — no project-file creation or edits until:** `binance-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. -**What the gate does not cover:** installing `binance-public-spot-api` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +**What the gate does not cover:** installing `binance` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. + +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. Before implementing, check the plan's **Assumptions & Blockers** section: -- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `binance-public-spot-api-plan.md` in place. +- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `binance-plan.md` in place. - Minor assumptions only → proceed. Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. ### Step 2 — Implement from the contract sheet -1. Read `binance-public-spot-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. +1. Read `binance-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. 2. **Decide which environment this deployment talks to, explicitly, before the first call.** Constructing the client with no options selects `ServerEnvironment.Production` **silently**, so an unset field is indistinguishable from a deliberate choice of the default. Bind it to configuration and fail startup if the (environment, credential) pair is not the one this deployment intends — a credential pointed at the wrong host is not a defect any call site can correct. 3. **Build the client once and hold it.** Resources are memoized lazy getters on it, so rebuilding one per request throws that state away. There is no `close()` — it owns a `fetch`, not a pool. 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from an operation whose success carries no body. `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `BinanceError` family, told apart by `err.kind` — and an unknown `serverEnvironment` throws `ConfigurationError` out of the client constructor, before any call exists. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `BINANCE_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions -A standalone Binance Public Spot API question with no code change: look it up in `typescript-getting-started` (and the map page or file its table names), then give the grounded answer, citing where it came from. When several questions arrive batched, answer them in one pass. Never answer from memory, even for "easy" questions. +A standalone Binance question with no code change: look it up in `typescript-getting-started` (and the map page or file its table names), then give the grounded answer, citing where it came from. When several questions arrive batched, answer them in one pass. Never answer from memory, even for "easy" questions. ## Anti-patterns — never do these - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. -- **Never write any Binance Public Spot API or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. -- **Don't web-search Binance Public Spot API topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Binance Public Spot API docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. +- **Never write any Binance or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Don't web-search Binance topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Binance docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. -- **Don't deep-import from the package** (`binance-public-spot-api/models/…`). The package root is the only entry and deep specifiers do not resolve; the map's `Source` paths are for reading, not importing. -- **Don't import `Error` unaliased.** This SDK exports a type named after a TypeScript/DOM global, so importing it plain shadows the global for the whole file. Alias on import from `binance-public-spot-api`. +- **Don't deep-import from the package** (`binance/models/…`). The package root is the only entry and deep specifiers do not resolve; the map's `Source` paths are for reading, not importing. +- **Don't import `Error` unaliased.** This SDK exports a type named after a TypeScript/DOM global, so importing it plain shadows the global for the whole file. Alias on import from `binance`. - **Don't vendor the SDK's `src/` into the project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly** to make a lookup possible. Install it properly (see `typescript-getting-started`); a clone-based shortcut breaks every import the moment the clone is deleted, and it does not put the map where the lookups expect it. - **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. diff --git a/plugins/binance/skills/typescript/typescript-models/SKILL.md b/plugins/binance/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/binance/skills/typescript/typescript-models/SKILL.md +++ b/plugins/binance/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/binance/skills/typescript/typescript-models/reference.md b/plugins/binance/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/binance/skills/typescript/typescript-models/reference.md +++ b/plugins/binance/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/binance/skills/typescript/typescript-testing/SKILL.md b/plugins/binance/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/binance/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/binance/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/coingecko/.claude-plugin/plugin.json b/plugins/coingecko/.claude-plugin/plugin.json index fee9b42bf..bd07322f5 100644 --- a/plugins/coingecko/.claude-plugin/plugin.json +++ b/plugins/coingecko/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "coingecko", "displayName": "Coin Gecko", - "version": "0.2.2", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated CoinGecko Demo API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/coingecko/.cursor-plugin/plugin.json b/plugins/coingecko/.cursor-plugin/plugin.json index fee9b42bf..bd07322f5 100644 --- a/plugins/coingecko/.cursor-plugin/plugin.json +++ b/plugins/coingecko/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "coingecko", "displayName": "Coin Gecko", - "version": "0.2.2", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated CoinGecko Demo API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/coingecko/plugin.json b/plugins/coingecko/plugin.json index fee9b42bf..bd07322f5 100644 --- a/plugins/coingecko/plugin.json +++ b/plugins/coingecko/plugin.json @@ -1,7 +1,7 @@ { "name": "coingecko", "displayName": "Coin Gecko", - "version": "0.2.2", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated CoinGecko Demo API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/coingecko/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/coingecko/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..c858bd088 100644 --- a/plugins/coingecko/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/coingecko/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,245 +1,91 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Coin Gecko .NET SDK in C# — this API authenticates with an API key in a header and an API key in a query parameter, and this skill gives the credential each scheme takes, the CoinGeckoClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Coin Gecko .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with an API key in a header and an API key in a query parameter. APIMatic surfaces each scheme as a **nullable credentials property** on `CoinGeckoClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) - -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). - -## Basic auth - -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` - -Sends `Authorization: Basic base64(username:password)`. - -## Bearer token +| Scheme | Property on `CoinGeckoClientOptions` | Credential | +| --- | --- | --- | +| API key — `x-cg-demo-api-key` header | `HeaderAuth` | `string` | +| API key — `x_cg_demo_api_key` query parameter | `QueryAuth` | `string` | -Set the configured token property on the options class to your access-token string: +**That table is the entire authentication surface of this SDK.** `CoinGecko.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. -```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; -``` +## API key (header) -Sends `Authorization: Bearer ACCESS_TOKEN`. +**API Provider Notes:** -## API key (header, query, or cookie) +> Learn how to [set up your API key](https://docs.coingecko.com/docs/setting-up-your-api-key) -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: +The key travels in the `x-cg-demo-api-key` request header. The header name is fixed by the generated scheme — you supply only the value: ```csharp -options.{ApiKeyProperty} = "API_KEY"; +options.HeaderAuth = "API_KEY"; ``` -## OAuth 2.0 — client credentials (machine-to-machine) +## API key (query parameter) -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; - -options.{OAuthProperty} = new OAuth2ClientCredentials -{ - ClientId = "...", - ClientSecret = "...", - Scope = "..." // optional -}; -``` +**API Provider Notes:** -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. +> Learn how to [set up your API key](https://docs.coingecko.com/docs/setting-up-your-api-key) -## OAuth 2.0 — authorization code (3-legged, with PKCE) +The key is appended to every request as the `x_cg_demo_api_key` query parameter. The parameter name is fixed by the generated scheme — you supply only the value: ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; - -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials -{ - ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE - RedirectUri = "https://app.example.com/callback", - Scope = "...", // optional - State = "...", // optional CSRF token - Pkce = PkceMethod.S256, // default; RFC 7636 - PromptForAuthorizationCode = async (authorizationUrl, ct) => - { - // Open/redirect the browser to authorizationUrl, then return the - // authorization code your redirect endpoint received. - return await GetCodeFromUserAsync(authorizationUrl, ct); - } -}; +options.QueryAuth = "API_KEY"; ``` -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. - -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: +⚠ **A query-string credential is logged by everything on the path.** Proxies, CDNs, load balancers and the provider's own access logs record full URLs, and this SDK's request logging is no exception. Treat the key as exposed to your infrastructure, and rotate it on that basis. -| PKCE | token request built | no secret ⇒ | -| --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | +## Combining the schemes -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. +This SDK generates more than one credentials property, and its operations compose them. You configure the composition by setting the relevant properties on `CoinGeckoClientOptions` — the generated client wires the rest. -## OAuth 2.0 — resource owner password - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; - -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` - -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. - -### Supplying your own token source - -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. - -```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) -``` - -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: - -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). - -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. - -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +**OR (`AuthSchemeAny`) — alternatives, tried in order.** Schemes with **no credentials configured are skipped, not tried**; the first configured scheme that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails. Configure exactly the one you intend to use — an extra credential set "just in case" changes which scheme authenticates the call. **Which one that is, is not a preference:** each scheme's **API Provider Notes** above are where the provider states when its scheme applies. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `CoinGeckoClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("CoinGeckoClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.HeaderAuth)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "CoinGeckoClient:HeaderAuth is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"CoinGeckoClient:HeaderAuth is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +Check every value **the scheme you chose** needs. The rows below are not one checklist: the schemes above are alternatives, so a row belonging to a scheme you are not using needs no value at all, and demanding one at startup blocks a boot that would have worked. Validate the rows whose property you set, and no others: + +| Value to validate | Feeds `CoinGeckoClientOptions` property | +| --- | --- | +| `HeaderAuth` | `HeaderAuth` | +| `QueryAuth` | `QueryAuth` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddCoinGeckoClient(options => ...)` callback when registering via DI. `CoinGeckoClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddCoinGeckoClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/coingecko/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/coingecko/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/coingecko/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/coingecko/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/coingecko/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/coingecko/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/coingecko/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/coingecko/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/coingecko/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/coingecko/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/coingecko/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/coingecko/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/coingecko/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/coingecko/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/coingecko/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/coingecko/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/coingecko/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/coingecko/skills/dotnet/dotnet-getting-started/SKILL.md index 1c14e7f83..5fd3ba4d6 100644 --- a/plugins/coingecko/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/coingecko/skills/dotnet/dotnet-getting-started/SKILL.md @@ -1,120 +1,123 @@ --- name: "dotnet-getting-started" -description: "CoinGecko Demo API .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-coin-gecko is the workflow that uses it." +description: "Coin Gecko .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-coin-gecko is the workflow that uses it." --- -# Getting started with the CoinGecko Demo API .NET SDK +# Getting started with the Coin Gecko .NET SDK -> **Who this skill is for.** This is the **map layer**: load it to ground every CoinGecko Demo API SDK fact. `dotnet-integrate-coin-gecko` is the workflow that drives it — you collect the contracts for the work in scope into `coin-gecko-demo-api-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. +> **Who this skill is for.** This is the **map layer**: load it to ground every Coin Gecko SDK fact. `dotnet-integrate-coin-gecko` is the workflow that drives it — you collect the contracts for the work in scope into `coin-gecko-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated .NET SDK (auth, calling endpoints, models, error handling, retries, testing), see the companion API-agnostic skills: `dotnet-client-initialization`, `dotnet-authentication`, `dotnet-calling-endpoints`, `dotnet-models`, `dotnet-error-handling`, `dotnet-configuration-resilience`, `dotnet-testing`. **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity | | | | --- | --- | -| API | CoinGecko Demo API | +| API | Coin Gecko | | NuGet package | *not published to NuGet — see *Install* below* | | Source repo | https://github.com/context-plugins/coingecko-csharp-sdk (branch `main` — the ref this map documents) | -| Root namespace | `CoinGeckoDemoApi` (the `using` namespace) | -| Client class | `CoinGeckoDemoApiClient` | -| Options class | `CoinGeckoDemoApiClientOptions` | +| Root namespace | `CoinGecko` (the `using` namespace) | +| Client class | `CoinGeckoClient` | +| Options class | `CoinGeckoClientOptions` | | Auth | **API key** in the `x-cg-demo-api-key` header — set `HeaderAuth`
**API key** in the `x_cg_demo_api_key` query parameter — set `QueryAuth` | | Environments | `ServerEnvironment.Production` *(default)* → `https://api.coingecko.com/api/v3` | -| Log environment variable | `COINGECKODEMOAPICLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Log environment variable | `COINGECKOCLIENT_LOG` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-coin-gecko` makes that binding); the **Integration workflow** below says which governs which step. ## Namespaces (using-directives) -The SDK splits its public types across **separate child namespaces**. C# does **not** import child namespaces transitively, so `using CoinGeckoDemoApi.Models;` alone does **not** make enums, union types, or error types visible — you get `CS0103`/`CS0246` ("name/type does not exist") on build. Add a separate `using` for each kind of type you reference: +The SDK splits its public types across **separate child namespaces**. C# does **not** import child namespaces transitively, so `using CoinGecko.Models;` alone does **not** make enums, union types, or error types visible — you get `CS0103`/`CS0246` ("name/type does not exist") on build. Add a separate `using` for each kind of type you reference: | Type | Namespace | | --- | --- | -| `CoinGeckoDemoApiClient` | `CoinGeckoDemoApi` | -| `CoinGeckoDemoApiClientOptions` | `CoinGeckoDemoApi` | -| `ServerEnvironment` | `CoinGeckoDemoApi.Servers` | +| `CoinGeckoClient` | `CoinGecko` | +| `CoinGeckoClientOptions` | `CoinGecko` | +| `ServerEnvironment` | `CoinGecko.Servers` | Models, enums, union types and the generated error types each sit in their own child namespace. The map's **Namespaces by content type** table maps each source directory to its namespace, so read the namespace off the path the map gives for the type; anything left ambiguous is settled by the `namespace` declaration at the top of the type's own file. ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Coin Gecko, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/CoinGecko/Models/`, `src/CoinGecko/Errors/` and `src/CoinGecko/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/CoinGecko/Models/`, enums `src/CoinGecko/Models/Enums/`, typed errors `src/CoinGecko/Errors/`, request records `src/CoinGecko/Requests/`, or `src/CoinGecko/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: -- Collect the contracts for **every** in-scope operation in **one** pass — signatures and error accessors from the map pages, then required fields with wire names and enum values from the declaring files those pages name — into the **contract sheet** in `coin-gecko-demo-api-plan.md`, then implement from the sheet. Do not re-open a map page or source file per field, and never re-look-up a fact the sheet already carries. +- Collect the contracts for **every** in-scope operation in **one** pass — signatures and error accessors from the map pages, then required fields with wire names and enum values from the declaring files those pages name — into the **contract sheet** in `coin-gecko-plan.md`, then implement from the sheet. Do not re-open a map page or source file per field, and never re-look-up a fact the sheet already carries. - When you do open a source file, read it **scoped**: with an offset/limit, or a search for the one symbol — never dump a whole file into the conversation. - Never open the SDK's `api-reference.md` — the map supersedes it. ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/coin-gecko-demo-api-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/coin-gecko-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. -**Clone once, up front**, into a fresh timestamped folder under `/coin-gecko-demo-api-sdk-src/`, and reuse that folder for the rest of your session: +**Clone once, up front**, into a fresh timestamped folder under `/coin-gecko-sdk-src/`, and reuse that folder for the rest of your session: ```bash # Linux/macOS: -dir="${TMPDIR:-/tmp}/coin-gecko-demo-api-sdk-src/$(date +%Y%m%d-%H%M%S)" +dir="${TMPDIR:-/tmp}/coin-gecko-sdk-src/$(date +%Y%m%d-%H%M%S)" git clone --filter=blob:none --branch main https://github.com/context-plugins/coingecko-csharp-sdk "$dir" # Reuse "$dir" for the rest of your session (it is your clone path). ``` ```powershell # Windows (PowerShell): -$dir = "$env:TEMP\coin-gecko-demo-api-sdk-src\$(Get-Date -Format yyyyMMdd-HHmmss)" +$dir = "$env:TEMP\coin-gecko-sdk-src\$(Get-Date -Format yyyyMMdd-HHmmss)" git clone --filter=blob:none --branch main https://github.com/context-plugins/coingecko-csharp-sdk $dir # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/coin-gecko-demo-api-sdk-src/`, **never** in the project repo, and its path never goes into `coin-gecko-demo-api-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `coin-gecko-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `CoinGeckoDemoApiClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/CoinGecko/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/CoinGecko/Models/` (+ `src/CoinGecko/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/CoinGecko/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/CoinGecko/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/CoinGecko/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/CoinGecko/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/CoinGecko/Servers/`, `src/CoinGecko/CoinGeckoClient.cs`, `src/CoinGecko/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one -The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and CoinGecko Demo API may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. +The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Coin Gecko may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything -Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's Type sources table names it) lists every field with its `[JsonPropertyName]` wire name. If the scope touches such a field, three generator facts decide your logging posture (all in `dotnet-configuration-resilience`): JSON request bodies are logged **verbatim** when `LogRequestBody` is on, with no redaction; form bodies are masked only by deny-list; and leaving `options.Logging.LoggerFactory` unset arms the `COINGECKODEMOAPICLIENT_LOG` environment variable, whose `trace` level forces body logging on with no code change and no deploy. So on any build that can carry such a field: `LogRequestBody` stays off, `LoggerFactory` is set explicitly in production, and your own diagnostics never echo a request body on those paths. +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's Type sources table names it) lists every field with its `[JsonPropertyName]` wire name. If the scope touches such a field, three generator facts decide your logging posture (all in `dotnet-configuration-resilience`): JSON request bodies are logged **verbatim** when `LogRequestBody` is on, with no redaction; form bodies are masked only by deny-list; and leaving `options.Logging.LoggerFactory` unset arms the `COINGECKOCLIENT_LOG` environment variable, whose `trace` level forces body logging on with no code change and no deploy. So on any build that can carry such a field: `LogRequestBody` stays off, `LoggerFactory` is set explicitly in production, and your own diagnostics never echo a request body on those paths. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. @@ -126,17 +129,17 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/CoinGecko/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step You loaded every companion the sheet named before starting (per `dotnet-integrate-coin-gecko`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new CoinGeckoDemoApiClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) -2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `CoinGeckoDemoApiClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new CoinGeckoClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) +2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `CoinGeckoClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/coingecko/skills/dotnet/dotnet-integrate-coin-gecko/SKILL.md b/plugins/coingecko/skills/dotnet/dotnet-integrate-coin-gecko/SKILL.md index 35b06410e..1b82cee4a 100644 --- a/plugins/coingecko/skills/dotnet/dotnet-integrate-coin-gecko/SKILL.md +++ b/plugins/coingecko/skills/dotnet/dotnet-integrate-coin-gecko/SKILL.md @@ -1,35 +1,35 @@ --- name: "dotnet-integrate-coin-gecko" -description: "MANDATORY FIRST STEP for CoinGecko Demo API .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate CoinGecko Demo API in C# — Coins, Derivatives, Entities, Exchanges, GlobalApi, Misc, Nfts, Onchain, PublicTreasuryApi, SearchApi, Simple — or when a CoinGecko Demo API .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file coin-gecko-demo-api-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." +description: "MANDATORY FIRST STEP for Coin Gecko .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Coin Gecko in C# — Coins, Derivatives, Entities, Exchanges, GlobalApi, Misc, Nfts, Onchain, PublicTreasuryApi, SearchApi, Simple — or when a Coin Gecko .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file coin-gecko-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." --- -# CoinGecko Demo API .NET SDK — Workflow (map + skills) +# Coin Gecko .NET SDK — Workflow (map + skills) -You are the CoinGecko Demo API .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. +You are the Coin Gecko .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. -**Scope guard:** the APIMatic-generated CoinGecko Demo API **.NET SDK** (root namespace `CoinGeckoDemoApi`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. +**Scope guard:** the APIMatic-generated Coin Gecko **.NET SDK** (root namespace `CoinGecko`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. ## Grounding rules -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `coin-gecko-demo-api-plan.md` — the plan must stay portable. +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `coin-gecko-plan.md` never carries its path — the plan must stay portable. - **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. - **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. - **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. -- **The sheet records CoinGecko Demo API's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. +- **The sheet records Coin Gecko's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. - **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. ## Workflow -**If the user opens with a reported SDK error or unexpected CoinGecko Demo API behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: +**If the user opens with a reported SDK error or unexpected Coin Gecko behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: -### Step 1 — Plan first: write `coin-gecko-demo-api-plan.md` (always, for any implementation work) +### Step 1 — Plan first: write `coin-gecko-plan.md` (always, for any implementation work) -Your FIRST deliverable is `coin-gecko-demo-api-plan.md` at `/coin-gecko-demo-api-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. +Your FIRST deliverable is `coin-gecko-plan.md` at `/coin-gecko-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. #### How to ground (map-first, one pass) 1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. 3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. 4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. 5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. @@ -38,7 +38,7 @@ Your FIRST deliverable is `coin-gecko-demo-api-plan.md` at `/ These need no SDK knowledge and touch no project file, so do them before or alongside the map work: -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); - the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; - locating the SDK reference and its version in the project; @@ -49,15 +49,28 @@ Never use this prerequisite work as a "head start" on implementation: **creating #### HARD GATE -**No project-file creation or edits until `coin-gecko-demo-api-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. +**No project-file creation or edits until `coin-gecko-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. -#### The `coin-gecko-demo-api-plan.md` format (keep it tight — tables, not prose) +#### The `coin-gecko-plan.md` format (keep it tight — tables, not prose) 1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + 3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. 6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. 7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. @@ -67,12 +80,46 @@ Never use this prerequisite work as a "head start" on implementation: **creating | 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | | 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | | 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | | 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | | 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | | 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. @@ -84,42 +131,43 @@ These are API-agnostic usage skills; loading them is not the same as reading the Before implementing, check the plan's **Assumptions & Blockers** section: -- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `coin-gecko-demo-api-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. +- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `coin-gecko-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. - Minor assumptions only → proceed. Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. ### Step 3 — Implement from the contract sheet -1. Read `coin-gecko-demo-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. +1. Read `coin-gecko-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. 2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. 3. After every change: `dotnet build`; fix non-SDK errors yourself. -4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `CoinGeckoDemoApi.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. +4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `CoinGecko.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. ### Step 4 — Fixing SDK errors (map-first, in place) -A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `CoinGeckoDemoApi.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. +A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `CoinGecko.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/CoinGecko/Models/{Type}.cs`, `src/CoinGecko/Models/Enums/{Type}.cs`, `src/CoinGecko/Errors/{Type}.cs`, `src/CoinGecko/Requests/{Type}.cs`, or `src/CoinGecko/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. 3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". 4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). 5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. 6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. -If a fix corrects a row in `coin-gecko-demo-api-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. +If a fix corrects a row in `coin-gecko-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. ### Step 5 — Pure questions -A standalone CoinGecko Demo API question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. +A standalone Coin Gecko question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. ## Anti-patterns — never do these -- **Never write a CoinGecko Demo API/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** +- **Never write a Coin Gecko/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** - **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. - **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. - **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search CoinGecko Demo API for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `CoinGeckoDemoApi` is what the generated source declares, not what public docs describe. +- **Do not web-search Coin Gecko for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `CoinGecko` is what the generated source declares, not what public docs describe. - **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. diff --git a/plugins/coingecko/skills/dotnet/dotnet-models/SKILL.md b/plugins/coingecko/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/coingecko/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/coingecko/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/coingecko/skills/dotnet/dotnet-models/reference.md b/plugins/coingecko/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/coingecko/skills/dotnet/dotnet-models/reference.md +++ b/plugins/coingecko/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/coingecko/skills/dotnet/dotnet-testing/SKILL.md b/plugins/coingecko/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/coingecko/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/coingecko/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/coingecko/skills/python/python-authentication/SKILL.md b/plugins/coingecko/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/coingecko/skills/python/python-authentication/SKILL.md +++ b/plugins/coingecko/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/coingecko/skills/python/python-calling-endpoints/SKILL.md b/plugins/coingecko/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/coingecko/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/coingecko/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/coingecko/skills/python/python-client-initialization/SKILL.md b/plugins/coingecko/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/coingecko/skills/python/python-client-initialization/SKILL.md +++ b/plugins/coingecko/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/coingecko/skills/python/python-configuration-resilience/SKILL.md b/plugins/coingecko/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/coingecko/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/coingecko/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/coingecko/skills/python/python-error-handling/SKILL.md b/plugins/coingecko/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/coingecko/skills/python/python-error-handling/SKILL.md +++ b/plugins/coingecko/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/coingecko/skills/python/python-event-streams/SKILL.md b/plugins/coingecko/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/coingecko/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/coingecko/skills/python/python-file-handling/SKILL.md b/plugins/coingecko/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/coingecko/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/coingecko/skills/python/python-getting-started/SKILL.md b/plugins/coingecko/skills/python/python-getting-started/SKILL.md index 4856423ef..5c1b8d3e6 100644 --- a/plugins/coingecko/skills/python/python-getting-started/SKILL.md +++ b/plugins/coingecko/skills/python/python-getting-started/SKILL.md @@ -1,11 +1,11 @@ --- name: "python-getting-started" -description: "CoinGecko Demo API Python SDK identity and lookup layer (Python only) — install, import root, base URL/environments, the auth pattern, the SDK map that ships at the SDK root (`sdk-map.md` + `map/operations/`) and how to traverse it, and the module table naming the one file owning each fact the map leaves to the source. Load this before answering any CoinGecko Demo API Python SDK contract question or writing any SDK code." +description: "Coin Gecko Python SDK identity and lookup layer (Python only) — install, import root, base URL/environments, the auth pattern, the SDK map that ships at the SDK root (`sdk-map.md` + `map/operations/`) and how to traverse it, and the module table naming the one file owning each fact the map leaves to the source. Load this before answering any Coin Gecko Python SDK contract question or writing any SDK code." --- -# Getting started with the CoinGecko Demo API Python SDK +# Getting started with the Coin Gecko Python SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing CoinGecko Demo API Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-coin-gecko` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Coin Gecko Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-coin-gecko` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated Python SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `python-client-initialization`, `python-authentication`, `python-calling-endpoints`, `python-models`, `python-error-handling`, `python-configuration-resilience` and `python-testing`. @@ -13,24 +13,24 @@ This is the **SDK-specific** entry point. For general patterns that apply to any ## SDK identity -Verified against `coin_gecko_demo_api/` and `pyproject.toml` of the generated package at version `3.0.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. +Verified against `coin_gecko/` and `pyproject.toml` of the generated package at version `3.0.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. | Fact | Value | | --- | --- | -| API | CoinGecko Demo API | -| Distribution name (what you install) | `coin-gecko-demo-api` — **not on any package index**; installed from source (see *Install*) | -| Import root (what you import) | `coin_gecko_demo_api` — not the string you install | +| API | Coin Gecko | +| Distribution name (what you install) | `coin-gecko` — **not on any package index**; installed from source (see *Install*) | +| Import root (what you import) | `coin_gecko` — not the string you install | | Source repository | https://github.com/context-plugins/coingecko-python-sdk | | Source branch | `main` | | Version | `3.0.0` | -| Sync client class | `CoinGeckoDemoApiClient` (alias `Client`) | -| Async client class | `AsyncCoinGeckoDemoApiClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`) · `header_auth` · `query_auth`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Sync client class | `CoinGeckoClient` (alias `Client`) | +| Async client class | `AsyncCoinGeckoClient` (alias `AsyncClient`) | +| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`) · `retry_options` · `header_auth` · `query_auth`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | **API key** in the `x-cg-demo-api-key` header — set `header_auth` · **API key** in the `x_cg_demo_api_key` query parameter — set `query_auth` | | Environments | **no environment enum** — one `base_url` string, defaulting to `https://api.coingecko.com/api/v3` | -| Base-URL config | `ServerConfig` (`coin_gecko_demo_api/server/server_config.py`), frozen, `extra="forbid"` | +| Base-URL config | `ServerConfig` (`coin_gecko/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | | Surface | 61 operations across 11 controllers · 192 models · 3 unions · 28 enums · 0 per-operation error unions | @@ -39,26 +39,31 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash -pip install "coin-gecko-demo-api @ git+https://github.com/context-plugins/coingecko-python-sdk.git@main" +pip install "coin-gecko @ git+https://github.com/context-plugins/coingecko-python-sdk.git@main" +python -c "import coin_gecko, pathlib; print(pathlib.Path(coin_gecko.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules -Python does not re-export child modules transitively, so `from coin_gecko_demo_api import models` alone does **not** make enums, error unions, or runtime types reachable. Import each kind of type from the module that owns it. +Python does not re-export child modules transitively, so `from coin_gecko import models` alone does **not** make enums, error unions, or runtime types reachable. Import each kind of type from the module that owns it. -`coin_gecko_demo_api/__init__.py` exports exactly 5 names beside the `coin_gecko_demo_api.models` subpackage it re-exports: +`coin_gecko/__init__.py` exports exactly 5 names beside the `coin_gecko.models` subpackage it re-exports: ```python -from coin_gecko_demo_api import ( +from coin_gecko import ( AsyncClient, - AsyncCoinGeckoDemoApiClient, + AsyncCoinGeckoClient, Client, - CoinGeckoDemoApiClient, + CoinGeckoClient, ServerConfig, ) ``` @@ -67,16 +72,16 @@ Everything else comes from its own subpackage, and the split matters because eac | What you need | Where it lives | | --- | --- | -| Domain models, their `…Dict` companions | `coin_gecko_demo_api.models` | -| Enums (and their open `…OrStr` aliases) | `coin_gecko_demo_api.models.enums` | -| `ApiError` · `RawError` · `ApiResult` · `RequestOptions` · `HttpClient` · `SdkBaseModel` · `UNSET` · `Optional` | `coin_gecko_demo_api.core` | -| Per-operation error *unions* | `coin_gecko_demo_api.errors` — *this SDK declares none* | +| Domain models, their `…Dict` companions | `coin_gecko.models` | +| Enums (and their open `…OrStr` aliases) | `coin_gecko.models.enums` | +| `ApiError` · `RawError` · `ApiResult` · `RequestOptions` · `HttpClient` · `SdkBaseModel` · `UNSET` · `Optional` | `coin_gecko.core` | +| Per-operation error *unions* | `coin_gecko.errors` — *this SDK declares none* | -`coin_gecko_demo_api.core` re-exports its whole public surface (a curated `__all__`), so import from `…core` rather than from the private modules beneath it (`…core.results`, `…core.exceptions`, `…core.auth.schemes`). +`coin_gecko.core` re-exports its whole public surface (a curated `__all__`), so import from `…core` rather than from the private modules beneath it (`…core.results`, `…core.exceptions`, `…core.auth.schemes`). ## Environments — there is no environment enum -This SDK has **no environment type and no environment constants**. There is one knob: `base_url`, on `ServerConfig` (`coin_gecko_demo_api/server/server_config.py`), and its default is **`https://api.coingecko.com/api/v3`**: +This SDK has **no environment type and no environment constants**. There is one knob: `base_url`, on `ServerConfig` (`coin_gecko/server/server_config.py`), and its default is **`https://api.coingecko.com/api/v3`**: ```python base_url: str = "https://api.coingecko.com/api/v3" @@ -93,7 +98,7 @@ Consequences to state on every contract sheet that touches configuration: An API key sent as the `x-cg-demo-api-key` header, exposed as the client's `header_auth=` keyword taking a plain string. ```python -from coin_gecko_demo_api import Client +from coin_gecko import Client client = Client(header_auth="") ``` @@ -101,7 +106,7 @@ client = Client(header_auth="") An API key sent as the `x_cg_demo_api_key` query parameter, exposed as the client's `query_auth=` keyword taking a plain string. ```python -from coin_gecko_demo_api import Client +from coin_gecko import Client client = Client(query_auth="") ``` @@ -134,24 +139,24 @@ The SDK map's controller table carries the same counts and links to a page per c ## SDK map — look up first, open the module second -The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `coin_gecko_demo_api/` source directory, and these two entries: +The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `coin_gecko/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `coin_gecko_demo_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `coin_gecko/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/coingecko-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. -Every `Source` path on the map is relative to that SDK root, so `coin_gecko_demo_api/models/asset_platform.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. +Every `Source` path on the map is relative to that SDK root, so `coin_gecko/models/asset_platform.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. **The map is the locator; the source modules are the shapes.** Read the map first — signatures, routes, parameter roles, return types, error unions, and which module declares a type are all answered there without opening a single `.py` file. Then open the one module the map names for what it deliberately does not carry: a model's members, an enum's values, a field's wire alias. The map says so itself — *"Shapes live only in the source … Never grep for a type."* -**The map carries shapes; what an operation *means* lives elsewhere.** When *what* to pass depends on meaning — which values a field accepts beyond its type, a rule that couples two fields, what a defaulted parameter actually selects — the map will not settle it. Open the operation's docstring in `coin_gecko_demo_api/apis/.py` (or `client.py` where operations sit on the client) and `api-reference.md` at the SDK root for that operation *before* writing the sheet row, and record what you found there. A value you already "know" for a field the map types as a plain `str` is a lookup, not a recall — the memory ban applies to it. +**The map carries shapes; what an operation *means* lives elsewhere.** When *what* to pass depends on meaning — which values a field accepts beyond its type, a rule that couples two fields, what a defaulted parameter actually selects — the map will not settle it. Open the operation's docstring in `coin_gecko/apis/.py` (or `client.py` where operations sit on the client) and `api-reference.md` at the SDK root for that operation *before* writing the sheet row, and record what you found there. A value you already "know" for a field the map types as a plain `str` is a lookup, not a recall — the memory ban applies to it. `sdk-map.md` carries the invariants every operation block assumes, so load it before any `map/operations/` page; the pages are written to be read beside it. @@ -162,10 +167,10 @@ Every `Source` path on the map is relative to that SDK root, so `coin_gecko_demo Read the one module that owns the fact **inside the installed package**. Locate it first: ```bash -python -c "import coin_gecko_demo_api, pathlib; print(pathlib.Path(coin_gecko_demo_api.__file__).parent)" +python -c "import coin_gecko, pathlib; print(pathlib.Path(coin_gecko.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/coin_gecko_demo_api` on Windows, `.venv/lib/python3.*/site-packages/coin_gecko_demo_api` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/coin_gecko` on Windows, `.venv/lib/python3.*/site-packages/coin_gecko` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -173,7 +178,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/co | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -183,7 +188,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/co | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | @@ -199,13 +204,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The two schemes are `header_auth=` and `query_auth=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`), so an unknown wire value passes through as a plain `str` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -216,8 +221,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 3. **Every operation returns a payload** — no operation in this SDK returns `None`, so the `with_raw_response` peer is needed only where the status code itself matters. 4. **Required vs `UNSET`** for every model member the task sets, and the fact that `Optional[T]` here is `T | UnsetType` — **not** `typing.Optional`, so `None` is not a legal value for it. 5. **The `ApiError.error` union** for each operation in scope — no operation in this SDK documents a typed error body, so `.error` is always `RawError`. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 55 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which host the `base_url` selects**, because omitting it is silently the default. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 55 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which host the `base_url` selects**, because omitting it is silently the default. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/coingecko/skills/python/python-integrate-coin-gecko/SKILL.md b/plugins/coingecko/skills/python/python-integrate-coin-gecko/SKILL.md index fd09eba61..a52055187 100644 --- a/plugins/coingecko/skills/python/python-integrate-coin-gecko/SKILL.md +++ b/plugins/coingecko/skills/python/python-integrate-coin-gecko/SKILL.md @@ -1,9 +1,9 @@ --- name: "python-integrate-coin-gecko" -description: "MANDATORY FIRST STEP for CoinGecko Demo API Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate CoinGecko Demo API in Python — to check the api server status — or when a CoinGecko Demo API Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." +description: "MANDATORY FIRST STEP for Coin Gecko Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Coin Gecko in Python — to check the api server status — or when a Coin Gecko Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." --- -# CoinGecko Demo API Python SDK — Integration workflow (lookup layer + contract sheet) +# Coin Gecko Python SDK — Integration workflow (lookup layer + contract sheet) You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. @@ -11,24 +11,24 @@ You do the SDK lookups yourself: `python-getting-started` is your **lookup layer - **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import coin_gecko_demo_api, pathlib; print(pathlib.Path(coin_gecko_demo_api.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import coin_gecko, pathlib; print(pathlib.Path(coin_gecko.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) - **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. -**Scope guard:** the APIMatic-generated CoinGecko Demo API **Python SDK** (import root `coin_gecko_demo_api`, distribution `coin-gecko-demo-api`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. +**Scope guard:** the APIMatic-generated Coin Gecko **Python SDK** (import root `coin_gecko`, distribution `coin-gecko`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. ## Workflow -**If the user opens with a reported SDK error or unexpected CoinGecko Demo API behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: +**If the user opens with a reported SDK error or unexpected Coin Gecko behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/coin-gecko-demo-api-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/coin-gecko-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `coin-gecko-demo-api` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; +- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `coin-gecko` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; - a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; - credentials/environment verification (per the task's secret-handling rules) — including which host `base_url` will select, since omitting it is silent; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; @@ -36,9 +36,24 @@ Your FIRST action is to load `python-getting-started` and work through the user' Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. -**HARD GATE — no project-file creation or edits until:** `coin-gecko-demo-api-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. +**HARD GATE — no project-file creation or edits until:** `coin-gecko-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. -**What the gate does not cover:** creating or activating a virtual environment, installing `coin-gecko-demo-api` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. +**What the gate does not cover:** creating or activating a virtual environment, installing `coin-gecko` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. ### Step 1c — Required reading (do this before you write any code) @@ -50,30 +65,31 @@ These are API-agnostic usage skills; loading them is not a substitute for the lo Before implementing, check the plan's **Assumptions & Blockers** section: -- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `coin-gecko-demo-api-plan.md` in place. +- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `coin-gecko-plan.md` in place. - Minor assumptions only → proceed. Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. ### Step 2 — Implement from the contract sheet -1. Read `coin-gecko-demo-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. +1. Read `coin-gecko-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. 2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). 3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. 4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `coin_gecko_demo_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `coin_gecko.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. ### Step 3 — Answering pure questions -A standalone CoinGecko Demo API question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. +A standalone Coin Gecko question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. ## Anti-patterns — never do these - **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. -- **Never write a CoinGecko Demo API/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Don't web-search CoinGecko Demo API topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public CoinGecko Demo API docs describe the REST API, not this SDK's generated surface, and the two disagree on names. +- **Never write a Coin Gecko/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** +- **Don't web-search Coin Gecko topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Coin Gecko docs describe the REST API, not this SDK's generated surface, and the two disagree on names. - **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. - **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. diff --git a/plugins/coingecko/skills/python/python-models/SKILL.md b/plugins/coingecko/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/coingecko/skills/python/python-models/SKILL.md +++ b/plugins/coingecko/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/coingecko/skills/python/python-models/reference.md b/plugins/coingecko/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/coingecko/skills/python/python-models/reference.md +++ b/plugins/coingecko/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/coingecko/skills/python/python-testing/SKILL.md b/plugins/coingecko/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/coingecko/skills/python/python-testing/SKILL.md +++ b/plugins/coingecko/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/coingecko/skills/typescript/typescript-authentication/SKILL.md b/plugins/coingecko/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/coingecko/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/coingecko/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/coingecko/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/coingecko/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/coingecko/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/coingecko/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/coingecko/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/coingecko/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/coingecko/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/coingecko/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/coingecko/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/coingecko/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/coingecko/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/coingecko/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/coingecko/skills/typescript/typescript-error-handling/SKILL.md b/plugins/coingecko/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/coingecko/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/coingecko/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/coingecko/skills/typescript/typescript-getting-started/SKILL.md b/plugins/coingecko/skills/typescript/typescript-getting-started/SKILL.md index 0c9b77d14..3de6c6d61 100644 --- a/plugins/coingecko/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/coingecko/skills/typescript/typescript-getting-started/SKILL.md @@ -1,11 +1,11 @@ --- name: "typescript-getting-started" -description: "CoinGecko Demo API TypeScript SDK identity and lookup layer (TypeScript/JavaScript only) — install, the single import specifier `coin-gecko-demo-api`, the server environments and the base-URL knob, the auth pattern, the SDK map that ships inside the installed package (`sdk-map.md` + `map/operations/`) and how to traverse it, and the file table naming the one source file owning each fact the map leaves to the source. Load this before answering any CoinGecko Demo API TypeScript SDK contract question or writing any SDK code." +description: "Coin Gecko TypeScript SDK identity and lookup layer (TypeScript/JavaScript only) — install, the single import specifier `coin-gecko`, the server environments and the base-URL knob, the auth pattern, the SDK map that ships inside the installed package (`sdk-map.md` + `map/operations/`) and how to traverse it, and the file table naming the one source file owning each fact the map leaves to the source. Load this before answering any Coin Gecko TypeScript SDK contract question or writing any SDK code." --- -# Getting started with the CoinGecko Demo API TypeScript SDK +# Getting started with the Coin Gecko TypeScript SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing CoinGecko Demo API TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-coin-gecko` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Coin Gecko TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-coin-gecko` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated TypeScript SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `typescript-client-initialization`, `typescript-authentication`, `typescript-calling-endpoints`, `typescript-models`, `typescript-error-handling`, `typescript-configuration-resilience` and `typescript-testing`. @@ -17,18 +17,20 @@ Verified against `package.json` and `sdk-map.md` of the generated package at ver | Fact | Value | | --- | --- | -| API | CoinGecko Demo API | -| Package name (what you install, and what you import) | `coin-gecko-demo-api` — **not on npm**; built from source (see *Install*) | -| Import specifier | `coin-gecko-demo-api` — the package root is the **only** entry; deep imports do not resolve | +| API | Coin Gecko | +| Package name (what you install, and what you import) | `coin-gecko` — **not on npm**; built from source (see *Install*) | +| Import specifier | `coin-gecko` — the package root is the **only** entry; deep imports do not resolve | +| Source repository | https://github.com/context-plugins/coingecko-typescript-sdk (branch `main` — the ref this map documents) | | Version | `3.0.0` (API spec version `3.0.0`) | -| Client class | `CoinGeckoDemoApiClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new CoinGeckoDemoApiClient(clientOptions: Partial = {})` — **every** field is optional, so `new CoinGeckoDemoApiClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `headerAuth` · `queryAuth`. `timeout` defaults to `60_000` ms | +| Client class | `CoinGeckoClient` (`src/client.ts`) — one class, no sync/async split | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new CoinGeckoClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new CoinGeckoClient()` compiles. Fields: `serverOptions` · `retry` · `fetch` · `headerAuth` · `queryAuth`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **API key (header)** — set `ClientOptions.headerAuth`
**API key (query)** — set `ClientOptions.queryAuth` | -| Environments | 1 environment (`ServerEnvironment.Production` *(default)*) × 1 server group | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`), defaulting to `https://api.coingecko.com/api/v3` | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Environments | no selector — one environment, so none is emitted; 1 server group | +| Base-URL config | `serverOptions.baseUrl` (`src/servers.ts`), defaulting to `https://api.coingecko.com/api/v3` | +| Log environment variable | `COIN_GECKO_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Surface | 61 operations · 11 resources · 192 models · 28 open enums · 3 unions · 0 per-operation error subclasses | @@ -37,48 +39,46 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/coingecko-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 223 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, 223 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { CoinGeckoDemoApiClient, ServerEnvironment, ResponseError, CoinGeckoDemoApiError } from "coin-gecko-demo-api"; -import type { ClientOptions, AssetPlatform } from "coin-gecko-demo-api"; +import { CoinGeckoClient, ApiError, CoinGeckoError } from "coin-gecko"; +import type { ClientOptions, AssetPlatform } from "coin-gecko"; ``` Things the specifier alone will not tell you: -- **Deep imports do not resolve.** The `exports` map exposes `.` and `./package.json` and nothing else, so `coin-gecko-demo-api/models/…` fails (`TS2307`) even though the file exists in the shipped `src/`. Every `Source` path on the SDK map is where to **read** a shape, never what to import. -- **From CommonJS**, the typed spelling is `import sdk = require("coin-gecko-demo-api")`. A plain `require` destructure runs but yields `any`. +- **Deep imports do not resolve.** The `exports` map exposes `.` and `./package.json` and nothing else, so `coin-gecko/models/…` fails (`TS2307`) even though the file exists in the shipped `src/`. Every `Source` path on the SDK map is where to **read** a shape, never what to import. +- **From CommonJS**, the typed spelling is `import sdk = require("coin-gecko")`. A plain `require` destructure runs but yields `any`. - **`instanceof` is reliable within one dialect.** A process that loads both (`import` in one file, `require` in another) gets two independent copies of every error class, and `instanceof` across that boundary is `false` — narrow on `err.kind` / `err.payload.kind` / `err.name` there. Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, every model type) must be imported with `import type`. Under `exactOptionalPropertyTypes`, **omit or spread** an absent optional field rather than assigning `undefined` to it. -## Environments +## Hosts -`ClientOptions.serverEnvironment` selects one environment for the whole client (`src/servers.ts`). `ServerEnvironment` is a `const` object with a derived union type — not a TypeScript `enum` — and unlike the model enums it is **closed**, so only its declared members are assignable. +This API declares one environment, so the SDK has **no environment selector** — there is no `ServerEnvironment` type and no field to set (`src/servers.ts`). The host below is the only one the SDK knows, and the override in the last column is the only way to reach another. -| Group | Environment | Base URL | Override at | -| --- | --- | --- | --- | -| `default` | `production` *(default)* | `https://api.coingecko.com/api/v3` | `serverOptions.default.production.baseUrl` | +| Group | Base URL | Override at | +| --- | --- | --- | +| `default` | `https://api.coingecko.com/api/v3` | `serverOptions.baseUrl` | Consequences to state on every contract sheet that touches configuration: -- Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- ⚠ **This SDK declares exactly one environment**, so reaching any other host is a `serverOptions` override, not an environment value — `serverOptions: { default: { production: { baseUrl: "…" } } }`. Note the key is still `production`: the environment name and the host it points at are now decoupled, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the deployment's intent explicit where the client is built, and verify it. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- ⚠ **This SDK declares exactly one environment**, so it emits no `ServerEnvironment` at all and reaching any other host is a `serverOptions` base-URL override — `serverOptions: { baseUrl: "…" }`. Nothing in the options then records **which** deployment this is, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the intent explicit where the client is built, and verify it. +- An override merges with the built-in default **key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. ## Auth pattern (2 schemes) @@ -90,7 +90,7 @@ Authentication is **per operation**: every operation declares the requirement it | `queryAuth` | API key (query) | query `x_cg_demo_api_key=` | ```ts -const client = new CoinGeckoDemoApiClient({ +const client = new CoinGeckoClient({ headerAuth: process.env.API_KEY!, queryAuth: process.env.API_KEY!, }); @@ -102,7 +102,7 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. See `typescript-authentication` for the full picture. @@ -124,7 +124,7 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | `client.searchApi` | `SearchApi` | 2 | `searchData` · `trendingSearch` | | `client.simple` | `Simple` | 3 | `simplePrice` · `simpleSupportedCurrencies` · `simpleTokenPrice` | -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** A name that collides inside its namespace is promoted to `RequestParams` instead — no operation in this SDK takes that spelling at this version. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -132,20 +132,20 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: ```bash -node -e "console.log(require.resolve('coin-gecko-demo-api/package.json'))" +node -e "console.log(require.resolve('coin-gecko/package.json'))" ``` -Failing that it is at `node_modules/coin-gecko-demo-api/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/coin-gecko/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. -Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/coin-gecko-demo-api/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. +Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/coin-gecko/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -153,9 +153,9 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. -The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/coin-gecko-demo-api/`: +The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/coin-gecko/`: | Question | File | | --- | --- | @@ -163,13 +163,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -183,29 +185,37 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `COIN_GECKO_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new CoinGeckoDemoApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new CoinGeckoClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) 2. **Authentication** — load **typescript-authentication** before you set credentials. The 2 schemes are `headerAuth`, `queryAuth` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **no operation resolves to `undefined` — every one of them carries a body**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `CoinGeckoDemoApiError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `CoinGeckoError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `COIN_GECKO_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK -Beyond the usual signatures and model members, a contract sheet for the CoinGecko Demo API TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. +Beyond the usual signatures and model members, a contract sheet for the Coin Gecko TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. -1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment, `ServerEnvironment.Production`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. +1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment and emits no `ServerEnvironment`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. 2. **no operation resolves to `undefined` — every one of them carries a body** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. No operation declares a typed error body, so all 61 reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. No operation declares a typed error body, so all 61 reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/coingecko/skills/typescript/typescript-integrate-coin-gecko/SKILL.md b/plugins/coingecko/skills/typescript/typescript-integrate-coin-gecko/SKILL.md index 2735635a2..3717da7d8 100644 --- a/plugins/coingecko/skills/typescript/typescript-integrate-coin-gecko/SKILL.md +++ b/plugins/coingecko/skills/typescript/typescript-integrate-coin-gecko/SKILL.md @@ -1,84 +1,160 @@ --- name: "typescript-integrate-coin-gecko" -description: "MANDATORY FIRST STEP for CoinGecko Demo API TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate CoinGecko Demo API in TypeScript/Node — coins categories, coins categories list, coins contract address, coins id, coins id history, coins id market chart, or when the CoinGecko Demo API TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `coin-gecko-demo-api` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (coin-gecko-demo-api-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares only ServerEnvironment.Production, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." +description: "MANDATORY FIRST STEP for Coin Gecko TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Coin Gecko in TypeScript/Node — coins categories, coins categories list, coins contract address, coins id, coins id history, coins id market chart, or when the Coin Gecko TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `coin-gecko` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (coin-gecko-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares one environment and no ServerEnvironment, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." --- -# CoinGecko Demo API TypeScript SDK — Integration workflow (lookup layer + contract sheet) +# Coin Gecko TypeScript SDK — Integration workflow (lookup layer + contract sheet) You do the SDK lookups yourself: `typescript-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source files its table names) before writing code. The `typescript-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. ## Your lookup layer - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. -- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/coin-gecko-demo-api/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('coin-gecko-demo-api/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/coin-gecko/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('coin-gecko/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. -**Scope guard:** the APIMatic-generated CoinGecko Demo API **TypeScript SDK** (package and import specifier `coin-gecko-demo-api`, client `CoinGeckoDemoApiClient`) in **TypeScript or JavaScript projects only**. Unrelated API, or any language other than TypeScript/JavaScript — do nothing; this skill does not apply. +**Scope guard:** the APIMatic-generated Coin Gecko **TypeScript SDK** (package and import specifier `coin-gecko`, client `CoinGeckoClient`) in **TypeScript or JavaScript projects only**. Unrelated API, or any language other than TypeScript/JavaScript — do nothing; this skill does not apply. ## Workflow -**If the user opens with a reported SDK error, a compile failure, or unexpected CoinGecko Demo API behaviour** (not new feature work), skip the plan-first flow: load `typescript-getting-started` and `typescript-error-handling`, look the failing symbol up on the map page or in the file the table names, and fix from what you find. Otherwise, for implementation work: +**If the user opens with a reported SDK error, a compile failure, or unexpected Coin Gecko behaviour** (not new feature work), skip the plan-first flow: load `typescript-getting-started` and `typescript-error-handling`, look the failing symbol up on the map page or in the file the table names, and fix from what you find. Otherwise, for implementation work: ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/coin-gecko-demo-api-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/coin-gecko-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, **module format — ESM or CJS, which changes how the SDK is imported and whether `instanceof` on its error classes is trustworthy**, and the runtime: Node, a bundled browser app, or an edge runtime) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- **establish the toolchain before you need it**: the package manager, whether `coin-gecko-demo-api` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; +- **establish the toolchain before you need it**: the package manager, whether `coin-gecko` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; - a baseline run of the project's checks (`tsc --noEmit`, the test runner, the linter — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment (`ServerEnvironment.Production`), so **any other host is a `serverOptions` base-URL override rather than an environment value**; +- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment, so it emits no `ServerEnvironment` at all and **any other host is a `serverOptions` base-URL override rather than an environment value**; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your credential may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; - setting up your task tracking. Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. -**HARD GATE — no project-file creation or edits until:** `coin-gecko-demo-api-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. +**HARD GATE — no project-file creation or edits until:** `coin-gecko-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. -**What the gate does not cover:** installing `coin-gecko-demo-api` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +**What the gate does not cover:** installing `coin-gecko` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. + +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. Before implementing, check the plan's **Assumptions & Blockers** section: -- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `coin-gecko-demo-api-plan.md` in place. +- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `coin-gecko-plan.md` in place. - Minor assumptions only → proceed. Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. ### Step 2 — Implement from the contract sheet -1. Read `coin-gecko-demo-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment — `ServerEnvironment.Production` — so a different host is a `serverOptions` override under a key still named after `production`: the environment name and the host it points at are decoupled, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. +1. Read `coin-gecko-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. +2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment and emits no selector for it, so a different host is a `serverOptions` base-URL override. Nothing in the options then records **which** deployment this is, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. 3. **Build the client once and hold it.** Resources are memoized lazy getters on it, so rebuilding one per request throws that state away. There is no `close()` — it owns a `fetch`, not a pool. 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from an operation whose success carries no body. `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `CoinGeckoError` family, told apart by `err.kind`. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `COIN_GECKO_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions -A standalone CoinGecko Demo API question with no code change: look it up in `typescript-getting-started` (and the map page or file its table names), then give the grounded answer, citing where it came from. When several questions arrive batched, answer them in one pass. Never answer from memory, even for "easy" questions. +A standalone Coin Gecko question with no code change: look it up in `typescript-getting-started` (and the map page or file its table names), then give the grounded answer, citing where it came from. When several questions arrive batched, answer them in one pass. Never answer from memory, even for "easy" questions. ## Anti-patterns — never do these - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. -- **Never write any CoinGecko Demo API or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. -- **Don't web-search CoinGecko Demo API topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public CoinGecko Demo API docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. +- **Never write any Coin Gecko or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Don't web-search Coin Gecko topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Coin Gecko docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. -- **Don't deep-import from the package** (`coin-gecko-demo-api/models/…`). The package root is the only entry and deep specifiers do not resolve; the map's `Source` paths are for reading, not importing. +- **Don't deep-import from the package** (`coin-gecko/models/…`). The package root is the only entry and deep specifiers do not resolve; the map's `Source` paths are for reading, not importing. - **Don't assume a name is a global.** The package root re-exports every public type, so an unaliased import shadows any same-named global for the rest of the file — check the name against the map before importing it plain. - **Don't vendor the SDK's `src/` into the project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly** to make a lookup possible. Install it properly (see `typescript-getting-started`); a clone-based shortcut breaks every import the moment the clone is deleted, and it does not put the map where the lookups expect it. - **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. diff --git a/plugins/coingecko/skills/typescript/typescript-models/SKILL.md b/plugins/coingecko/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/coingecko/skills/typescript/typescript-models/SKILL.md +++ b/plugins/coingecko/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/coingecko/skills/typescript/typescript-models/reference.md b/plugins/coingecko/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/coingecko/skills/typescript/typescript-models/reference.md +++ b/plugins/coingecko/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/coingecko/skills/typescript/typescript-testing/SKILL.md b/plugins/coingecko/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/coingecko/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/coingecko/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/deepgram/.claude-plugin/plugin.json b/plugins/deepgram/.claude-plugin/plugin.json index 11f1f79b0..6abc65813 100644 --- a/plugins/deepgram/.claude-plugin/plugin.json +++ b/plugins/deepgram/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "deepgram", "displayName": "Deepgram", - "version": "0.2.1", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Deepgram SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/deepgram/.cursor-plugin/plugin.json b/plugins/deepgram/.cursor-plugin/plugin.json index 11f1f79b0..6abc65813 100644 --- a/plugins/deepgram/.cursor-plugin/plugin.json +++ b/plugins/deepgram/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "deepgram", "displayName": "Deepgram", - "version": "0.2.1", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Deepgram SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/deepgram/plugin.json b/plugins/deepgram/plugin.json index 11f1f79b0..6abc65813 100644 --- a/plugins/deepgram/plugin.json +++ b/plugins/deepgram/plugin.json @@ -1,7 +1,7 @@ { "name": "deepgram", "displayName": "Deepgram", - "version": "0.2.1", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Deepgram SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/deepgram/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/deepgram/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..373e38857 100644 --- a/plugins/deepgram/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/deepgram/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,245 +1,91 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Deepgram .NET SDK in C# — this API authenticates with a Bearer token and an API key in a header, and this skill gives the credential each scheme takes, the DeepgramClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Deepgram .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with a Bearer token and an API key in a header. APIMatic surfaces each scheme as a **nullable credentials property** on `DeepgramClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) - -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). - -## Basic auth - -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` +| Scheme | Property on `DeepgramClientOptions` | Credential | +| --- | --- | --- | +| Bearer token | `JwtAuth` | `string` | +| API key — `Authorization` header | `ApiKeyAuth` | `string` | -Sends `Authorization: Basic base64(username:password)`. +**That table is the entire authentication surface of this SDK.** `Deepgram.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. ## Bearer token -Set the configured token property on the options class to your access-token string: - -```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; -``` - -Sends `Authorization: Bearer ACCESS_TOKEN`. - -## API key (header, query, or cookie) - -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: - -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` - -## OAuth 2.0 — client credentials (machine-to-machine) +**API Provider Notes:** -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; - -options.{OAuthProperty} = new OAuth2ClientCredentials -{ - ClientId = "...", - ClientSecret = "...", - Scope = "..." // optional -}; -``` +> Use `Authorization: Bearer ` +> Example: `Authorization: Bearer eyJhbGciOiJ...` -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. - -## OAuth 2.0 — authorization code (3-legged, with PKCE) +`options.JwtAuth` is a plain `string` — assign the access token itself, with no `Bearer` prefix, which the SDK adds: ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; - -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials -{ - ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE - RedirectUri = "https://app.example.com/callback", - Scope = "...", // optional - State = "...", // optional CSRF token - Pkce = PkceMethod.S256, // default; RFC 7636 - PromptForAuthorizationCode = async (authorizationUrl, ct) => - { - // Open/redirect the browser to authorizationUrl, then return the - // authorization code your redirect endpoint received. - return await GetCodeFromUserAsync(authorizationUrl, ct); - } -}; +options.JwtAuth = "ACCESS_TOKEN"; ``` -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. - -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: - -| PKCE | token request built | no secret ⇒ | -| --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | +Sends `Authorization: Bearer ACCESS_TOKEN`. -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. +## API key (header) -## OAuth 2.0 — resource owner password +**API Provider Notes:** -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; - -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` +> Use `Authorization: Token ` +> Example: `Authorization: Token 12345abcdef` -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. - -### Supplying your own token source - -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. +The key travels in the `Authorization` request header. The header name is fixed by the generated scheme — you supply only the value: ```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) +options.ApiKeyAuth = "API_KEY"; ``` -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: - -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). - -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. +## Combining the schemes -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +This SDK generates more than one credentials property, and its operations compose them. You configure the composition by setting the relevant properties on `DeepgramClientOptions` — the generated client wires the rest. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `DeepgramClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("DeepgramClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.JwtAuth)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "DeepgramClient:JwtAuth is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"DeepgramClient:JwtAuth is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +Check every value the schemes actually need. For this SDK that is: + +| Value to validate | Feeds `DeepgramClientOptions` property | +| --- | --- | +| `JwtAuth` | `JwtAuth` | +| `ApiKeyAuth` | `ApiKeyAuth` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddDeepgramClient(options => ...)` callback when registering via DI. `DeepgramClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddDeepgramClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/deepgram/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/deepgram/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/deepgram/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/deepgram/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/deepgram/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/deepgram/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/deepgram/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/deepgram/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/deepgram/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/deepgram/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/deepgram/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/deepgram/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/deepgram/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/deepgram/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/deepgram/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/deepgram/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException
` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/deepgram/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/deepgram/skills/dotnet/dotnet-getting-started/SKILL.md index ef9e628f0..6993ef817 100644 --- a/plugins/deepgram/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/deepgram/skills/dotnet/dotnet-getting-started/SKILL.md @@ -11,7 +11,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity @@ -26,7 +26,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any | Auth | **API key** in the `Authorization` header — set `ApiKeyAuth`
**Bearer** token — set `JwtAuth` | | Environments | `ServerEnvironment.Production` *(default)* → `https://agent.deepgram.com`
`ServerEnvironment.Environment2` → `https://api.deepgram.com` | | Log environment variable | `DEEPGRAMCLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-deepgram` makes that binding); the **Integration workflow** below says which governs which step. @@ -44,22 +44,24 @@ Models, enums, union types and the generated error types each sit in their own c ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Deepgram, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/Deepgram/Models/`, `src/Deepgram/Errors/` and `src/Deepgram/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/Deepgram/Models/`, enums `src/Deepgram/Models/Enums/`, typed errors `src/Deepgram/Errors/`, request records `src/Deepgram/Requests/`, or `src/Deepgram/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: @@ -69,7 +71,7 @@ Keep lookups cheap — the rules that keep a session's context small: ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/deepgram-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/deepgram-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. **Clone once, up front**, into a fresh timestamped folder under `/deepgram-sdk-src/`, and reuse that folder for the rest of your session: @@ -87,30 +89,31 @@ git clone --filter=blob:none --branch main https://github.com/context-plugins/de # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/deepgram-sdk-src/`, **never** in the project repo, and its path never goes into `deepgram-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `deepgram-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `DeepgramClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/Deepgram/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/Deepgram/Models/` (+ `src/Deepgram/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/Deepgram/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/Deepgram/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/Deepgram/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/Deepgram/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/Deepgram/Servers/`, `src/Deepgram/DeepgramClient.cs`, `src/Deepgram/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Deepgram may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything @@ -126,7 +129,7 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/Deepgram/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step @@ -134,9 +137,9 @@ You loaded every companion the sheet named before starting (per `dotnet-integrat 1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new DeepgramClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) 2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `DeepgramClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/deepgram/skills/dotnet/dotnet-integrate-deepgram/SKILL.md b/plugins/deepgram/skills/dotnet/dotnet-integrate-deepgram/SKILL.md index d9b8ce4d5..2338e2886 100644 --- a/plugins/deepgram/skills/dotnet/dotnet-integrate-deepgram/SKILL.md +++ b/plugins/deepgram/skills/dotnet/dotnet-integrate-deepgram/SKILL.md @@ -11,7 +11,7 @@ You are the Deepgram .NET SDK specialist for this task — one agent for every S ## Grounding rules -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `deepgram-plan.md` — the plan must stay portable. +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `deepgram-plan.md` never carries its path — the plan must stay portable. - **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. - **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. - **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. @@ -29,7 +29,7 @@ Your FIRST deliverable is `deepgram-plan.md` at `/deepgram-pl #### How to ground (map-first, one pass) 1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. 3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. 4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. 5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. @@ -38,7 +38,7 @@ Your FIRST deliverable is `deepgram-plan.md` at `/deepgram-pl These need no SDK knowledge and touch no project file, so do them before or alongside the map work: -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); - the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; - locating the SDK reference and its version in the project; @@ -54,10 +54,23 @@ Never use this prerequisite work as a "head start" on implementation: **creating #### The `deepgram-plan.md` format (keep it tight — tables, not prose) 1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + 3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. 6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. 7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. @@ -67,12 +80,46 @@ Never use this prerequisite work as a "head start" on implementation: **creating | 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | | 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | | 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | | 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | | 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | | 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. @@ -95,14 +142,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. 3. After every change: `dotnet build`; fix non-SDK errors yourself. 4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `Deepgram.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. ### Step 4 — Fixing SDK errors (map-first, in place) A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `Deepgram.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/Deepgram/Models/{Type}.cs`, `src/Deepgram/Models/Enums/{Type}.cs`, `src/Deepgram/Errors/{Type}.cs`, `src/Deepgram/Requests/{Type}.cs`, or `src/Deepgram/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. 3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". 4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). 5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. @@ -120,6 +168,6 @@ A standalone Deepgram question with no code change: load `dotnet-getting-started - **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. - **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. - **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search Deepgram for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `Deepgram` is what the generated source declares, not what public docs describe. +- **Do not web-search Deepgram for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `Deepgram` is what the generated source declares, not what public docs describe. - **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. diff --git a/plugins/deepgram/skills/dotnet/dotnet-models/SKILL.md b/plugins/deepgram/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/deepgram/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/deepgram/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/deepgram/skills/dotnet/dotnet-models/reference.md b/plugins/deepgram/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/deepgram/skills/dotnet/dotnet-models/reference.md +++ b/plugins/deepgram/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/deepgram/skills/dotnet/dotnet-testing/SKILL.md b/plugins/deepgram/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/deepgram/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/deepgram/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/deepgram/skills/python/python-authentication/SKILL.md b/plugins/deepgram/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/deepgram/skills/python/python-authentication/SKILL.md +++ b/plugins/deepgram/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/deepgram/skills/python/python-calling-endpoints/SKILL.md b/plugins/deepgram/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/deepgram/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/deepgram/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/deepgram/skills/python/python-client-initialization/SKILL.md b/plugins/deepgram/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/deepgram/skills/python/python-client-initialization/SKILL.md +++ b/plugins/deepgram/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/deepgram/skills/python/python-configuration-resilience/SKILL.md b/plugins/deepgram/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/deepgram/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/deepgram/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/deepgram/skills/python/python-error-handling/SKILL.md b/plugins/deepgram/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/deepgram/skills/python/python-error-handling/SKILL.md +++ b/plugins/deepgram/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/deepgram/skills/python/python-event-streams/SKILL.md b/plugins/deepgram/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/deepgram/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/deepgram/skills/python/python-file-handling/SKILL.md b/plugins/deepgram/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/deepgram/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/deepgram/skills/python/python-getting-started/SKILL.md b/plugins/deepgram/skills/python/python-getting-started/SKILL.md index 7ee2978c5..202bd8489 100644 --- a/plugins/deepgram/skills/python/python-getting-started/SKILL.md +++ b/plugins/deepgram/skills/python/python-getting-started/SKILL.md @@ -25,12 +25,12 @@ Verified against `deepgram/` and `pyproject.toml` of the generated package at ve | Version | `1.0.0` | | Sync client class | `DeepgramClient` (alias `Client`) | | Async client class | `AsyncDeepgramClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `environment` · `base_url` · `timeout` (default `30.0`) · `api_key_auth` · `jwt_auth`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Client construction | **keyword-only**: `environment` · `base_url` · `timeout` (default `30.0`) · `retry_options` · `api_key_auth` · `jwt_auth`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | **API key** in the `Authorization` header — set `api_key_auth` · **Bearer** token — set `jwt_auth` | | Environments | 2 environments selected by `environment` (default `"production"`), overridable with `base_url` | | Base-URL config | `ServerConfig` (`deepgram/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | | Surface | 50 operations across 24 controllers · 139 models · 31 unions · 73 enums · 50 per-operation error unions | @@ -39,13 +39,18 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash pip install "deepgram @ git+https://github.com/context-plugins/deepgram-python-sdk.git@main" +python -c "import deepgram, pathlib; print(pathlib.Path(deepgram.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules @@ -153,15 +158,15 @@ The SDK map's controller table carries the same counts and links to a page per c The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `deepgram/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `deepgram/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `deepgram/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/deepgram-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. Every `Source` path on the map is relative to that SDK root, so `deepgram/models/agent_configuration_v1.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. @@ -181,7 +186,7 @@ Read the one module that owns the fact **inside the installed package**. Locate python -c "import deepgram, pathlib; print(pathlib.Path(deepgram.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/deepgram` on Windows, `.venv/lib/python3.*/site-packages/deepgram` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/deepgram` on Windows, `.venv/lib/python3.*/site-packages/deepgram` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -189,7 +194,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/de | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -199,7 +204,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/de | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | | An operation's error mapper (status → schema) | `errors/_error.py` | @@ -216,13 +221,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The two schemes are `api_key_auth=` and `jwt_auth=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`), so an unknown wire value passes through as a plain `str` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -236,8 +241,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 1. `ErrorResponse` — 49 operations (`agent_v1_settings_think_models.list_` · `auth_v1_tokens.grant` · `manage_v1_models.get5` · `manage_v1_models.list6` · `manage_v1_projects.delete3` · `manage_v1_projects.get3` · `manage_v1_projects.leave` · `manage_v1_projects.list4` · `manage_v1_projects.update3` · `manage_v1_projects_billing_balances.get10` · `manage_v1_projects_billing_balances.list13` · `manage_v1_projects_billing_breakdown.list14` · `manage_v1_projects_billing_fields.list15` · `manage_v1_projects_billing_purchases.list16` · `manage_v1_projects_keys.create3` · `manage_v1_projects_keys.delete4` · `manage_v1_projects_keys.get6` · `manage_v1_projects_keys.list7` · `manage_v1_projects_members.delete5` · `manage_v1_projects_members.list8` · `manage_v1_projects_members_invites.create4` · `manage_v1_projects_members_invites.delete6` · `manage_v1_projects_members_invites.list10` · `manage_v1_projects_members_scopes.list9` · `manage_v1_projects_members_scopes.update4` · `manage_v1_projects_models.get4` · `manage_v1_projects_models.list5` · `manage_v1_projects_requests.get7` · `manage_v1_projects_requests.list11` · `manage_v1_projects_usage.get8` · `manage_v1_projects_usage_breakdown.get9` · `manage_v1_projects_usage_fields.list12` · `read_v1_text.analyze` · `self_hosted_v1_distribution_credentials.create5` · `self_hosted_v1_distribution_credentials.delete7` · `self_hosted_v1_distribution_credentials.get11` · `self_hosted_v1_distribution_credentials.list17` · `speak_v1_audio.generate` · `speak_v2_audio.generate2` · `voice_agent_configurations.create` · `voice_agent_configurations.delete` · `voice_agent_configurations.get` · `voice_agent_configurations.list2` · `voice_agent_configurations.update` · `voice_agent_variables.create2` · `voice_agent_variables.delete2` · `voice_agent_variables.get2` · `voice_agent_variables.list3` · `voice_agent_variables.update2`); distinguishing members *none required* 2. `ListenV1Response` — 1 operation (`listen_v1_media.transcribe`); distinguishing members `metadata` · `results` 3. So `isinstance(e.error, ErrorResponse)` matches only 49 of 50 operations. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 15 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"` of the 2 declared. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 15 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"` of the 2 declared. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/deepgram/skills/python/python-integrate-deepgram/SKILL.md b/plugins/deepgram/skills/python/python-integrate-deepgram/SKILL.md index 428cc4cdf..f7436b412 100644 --- a/plugins/deepgram/skills/python/python-integrate-deepgram/SKILL.md +++ b/plugins/deepgram/skills/python/python-integrate-deepgram/SKILL.md @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `python-getting-started` is your **lookup layer - **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import deepgram, pathlib; print(pathlib.Path(deepgram.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import deepgram, pathlib; print(pathlib.Path(deepgram.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) - **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. @@ -40,6 +40,21 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** creating or activating a virtual environment, installing `deepgram` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. @@ -62,8 +77,9 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. 4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `deepgram.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `deepgram.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. ### Step 3 — Answering pure questions diff --git a/plugins/deepgram/skills/python/python-models/SKILL.md b/plugins/deepgram/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/deepgram/skills/python/python-models/SKILL.md +++ b/plugins/deepgram/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/deepgram/skills/python/python-models/reference.md b/plugins/deepgram/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/deepgram/skills/python/python-models/reference.md +++ b/plugins/deepgram/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/deepgram/skills/python/python-testing/SKILL.md b/plugins/deepgram/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/deepgram/skills/python/python-testing/SKILL.md +++ b/plugins/deepgram/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/deepgram/skills/typescript/typescript-authentication/SKILL.md b/plugins/deepgram/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/deepgram/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/deepgram/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/deepgram/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/deepgram/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/deepgram/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/deepgram/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/deepgram/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/deepgram/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/deepgram/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/deepgram/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/deepgram/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/deepgram/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/deepgram/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/deepgram/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/deepgram/skills/typescript/typescript-error-handling/SKILL.md b/plugins/deepgram/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/deepgram/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/deepgram/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/deepgram/skills/typescript/typescript-getting-started/SKILL.md b/plugins/deepgram/skills/typescript/typescript-getting-started/SKILL.md index 43a19aea9..a609ceb9d 100644 --- a/plugins/deepgram/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/deepgram/skills/typescript/typescript-getting-started/SKILL.md @@ -20,15 +20,17 @@ Verified against `package.json` and `sdk-map.md` of the generated package at ver | API | Deepgram | | Package name (what you install, and what you import) | `deepgram` — **not on npm**; built from source (see *Install*) | | Import specifier | `deepgram` — the package root is the **only** entry; deep imports do not resolve | +| Source repository | https://github.com/context-plugins/deepgram-typescript-sdk (branch `main` — the ref this map documents) | | Version | `1.0.0` (API spec version `1.0.0`) | | Client class | `DeepgramClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new DeepgramClient(clientOptions: Partial = {})` — **every** field is optional, so `new DeepgramClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `apiKeyAuth` · `jwtAuth`. `timeout` defaults to `60_000` ms | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new DeepgramClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new DeepgramClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `retry` · `fetch` · `apiKeyAuth` · `jwtAuth`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **API key (header)** — set `ClientOptions.apiKeyAuth`
**Bearer token** — set `ClientOptions.jwtAuth` | | Environments | 2 environments (`ServerEnvironment.Production` *(default)*, `ServerEnvironment.Environment2`) × 1 server group | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`), defaulting to `https://agent.deepgram.com` | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Base-URL config | `serverOptions.baseUrl` (`src/servers.ts`), defaulting to `https://agent.deepgram.com` | +| Log environment variable | `DEEPGRAM_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Surface | 50 operations · 24 resources · 139 models · 73 open enums · 32 unions · 50 per-operation error subclasses | @@ -37,22 +39,22 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/deepgram-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 244 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 244 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { DeepgramClient, ServerEnvironment, ResponseError, DeepgramError } from "deepgram"; +import { DeepgramClient, ServerEnvironment, ApiError, DeepgramError } from "deepgram"; import type { ClientOptions, AgentConfigurationV1 } from "deepgram"; ``` @@ -70,15 +72,15 @@ Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, | Group | Environment | Base URL | Override at | | --- | --- | --- | --- | -| `default` | `production` *(default)* | `https://agent.deepgram.com` | `serverOptions.default.production.baseUrl` | -| `default` | `environment2` | `https://api.deepgram.com` | `serverOptions.default.environment2.baseUrl` | +| `default` | `production` *(default)* | `https://agent.deepgram.com` | `serverOptions.baseUrl` | +| `default` | `environment2` | `https://api.deepgram.com` | `serverOptions.baseUrl` | Consequences to state on every contract sheet that touches configuration: - Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. +- An environment value the SDK does not know throws `ConfigurationError` **from the constructor** — every server group is resolved once, as the client is built — so no operation method throws synchronously. ## Auth pattern (2 schemes) @@ -102,7 +104,7 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. See `typescript-authentication` for the full picture. @@ -137,7 +139,7 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | `client.authV1Tokens` | `AuthV1Tokens` | 1 | `grant` | | `client.speakV2Audio` | `SpeakV2Audio` | 1 | `generate2` | -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** A name that collides inside its namespace is promoted to `RequestParams` instead — no operation in this SDK takes that spelling at this version. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -145,7 +147,7 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: @@ -154,11 +156,11 @@ Locate the installed package before you rely on a lookup: node -e "console.log(require.resolve('deepgram/package.json'))" ``` -Failing that it is at `node_modules/deepgram/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/deepgram/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/deepgram/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -166,7 +168,7 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/deepgram/`: @@ -176,13 +178,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -196,17 +200,23 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `DEEPGRAM_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new DeepgramClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new DeepgramClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) 2. **Authentication** — load **typescript-authentication** before you set credentials. The 2 schemes are `apiKeyAuth`, `jwtAuth` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **no operation resolves to `undefined` — every one of them carries a body**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `DeepgramError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `DeepgramError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `DEEPGRAM_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK @@ -215,10 +225,12 @@ Beyond the usual signatures and model members, a contract sheet for the Deepgram 1. **Which host each deployment talks to**, and where that is set. The members are `ServerEnvironment.Production`, `ServerEnvironment.Environment2`, defaulting to `ServerEnvironment.Production` when the field is unset. 2. **no operation resolves to `undefined` — every one of them carries a body** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 50 of 50 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 50 of 50 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/deepgram/skills/typescript/typescript-integrate-deepgram/SKILL.md b/plugins/deepgram/skills/typescript/typescript-integrate-deepgram/SKILL.md index 58ee7d223..d392a14dc 100644 --- a/plugins/deepgram/skills/typescript/typescript-integrate-deepgram/SKILL.md +++ b/plugins/deepgram/skills/typescript/typescript-integrate-deepgram/SKILL.md @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/deepgram/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('deepgram/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('deepgram/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. @@ -23,7 +23,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/deepgram-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/deepgram-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** @@ -40,11 +40,85 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** installing `deepgram` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. @@ -63,8 +137,10 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from an operation whose success carries no body. `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `DeepgramError` family, told apart by `err.kind` — and an unknown `serverEnvironment` throws `ConfigurationError` out of the client constructor, before any call exists. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `DEEPGRAM_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions @@ -74,7 +150,7 @@ A standalone Deepgram question with no code change: look it up in `typescript-ge - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. - **Never write any Deepgram or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. - **Don't web-search Deepgram topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Deepgram docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. diff --git a/plugins/deepgram/skills/typescript/typescript-models/SKILL.md b/plugins/deepgram/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/deepgram/skills/typescript/typescript-models/SKILL.md +++ b/plugins/deepgram/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/deepgram/skills/typescript/typescript-models/reference.md b/plugins/deepgram/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/deepgram/skills/typescript/typescript-models/reference.md +++ b/plugins/deepgram/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/deepgram/skills/typescript/typescript-testing/SKILL.md b/plugins/deepgram/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/deepgram/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/deepgram/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/discourse/.claude-plugin/plugin.json b/plugins/discourse/.claude-plugin/plugin.json index 8c5e71d0f..31374e4fc 100644 --- a/plugins/discourse/.claude-plugin/plugin.json +++ b/plugins/discourse/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "discourse", "displayName": "Discourse", - "version": "0.2.1", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Discourse SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/discourse/.cursor-plugin/plugin.json b/plugins/discourse/.cursor-plugin/plugin.json index 8c5e71d0f..31374e4fc 100644 --- a/plugins/discourse/.cursor-plugin/plugin.json +++ b/plugins/discourse/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "discourse", "displayName": "Discourse", - "version": "0.2.1", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Discourse SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/discourse/plugin.json b/plugins/discourse/plugin.json index 8c5e71d0f..31374e4fc 100644 --- a/plugins/discourse/plugin.json +++ b/plugins/discourse/plugin.json @@ -1,7 +1,7 @@ { "name": "discourse", "displayName": "Discourse", - "version": "0.2.1", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Discourse SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/discourse/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/discourse/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..d1886ba8d 100644 --- a/plugins/discourse/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/discourse/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,245 +1,19 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Discourse .NET SDK in C# — this API declares no security schemes, so DiscourseClientOptions exposes no credentials properties at all. Load to confirm that before hunting for an auth knob, or when a call unexpectedly comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Discourse .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +**The Discourse API declares no security schemes, so this SDK generates no credentials properties at all.** `DiscourseClientOptions` has no authentication surface — there is nothing to set and no scheme to configure. Construct the client as `dotnet-client-initialization` describes and call operations directly. -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. +`Discourse.Core.Authentication` still ships the scheme classes as shared runtime code — every APIMatic .NET SDK carries that folder whether its API uses it or not. Its presence is **not** evidence of an auth knob you have missed. -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) +## Operations that need no credentials -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). - -## Basic auth - -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` - -Sends `Authorization: Basic base64(username:password)`. - -## Bearer token - -Set the configured token property on the options class to your access-token string: - -```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; -``` - -Sends `Authorization: Bearer ACCESS_TOKEN`. - -## API key (header, query, or cookie) - -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: - -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` - -## OAuth 2.0 — client credentials (machine-to-machine) - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; - -options.{OAuthProperty} = new OAuth2ClientCredentials -{ - ClientId = "...", - ClientSecret = "...", - Scope = "..." // optional -}; -``` - -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. - -## OAuth 2.0 — authorization code (3-legged, with PKCE) - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; - -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials -{ - ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE - RedirectUri = "https://app.example.com/callback", - Scope = "...", // optional - State = "...", // optional CSRF token - Pkce = PkceMethod.S256, // default; RFC 7636 - PromptForAuthorizationCode = async (authorizationUrl, ct) => - { - // Open/redirect the browser to authorizationUrl, then return the - // authorization code your redirect endpoint received. - return await GetCodeFromUserAsync(authorizationUrl, ct); - } -}; -``` - -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. - -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: - -| PKCE | token request built | no secret ⇒ | -| --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | - -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. - -## OAuth 2.0 — resource owner password - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; - -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` - -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. - -### Supplying your own token source - -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. - -```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) -``` - -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: - -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). - -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. - -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. - -## ⚠ Missing credentials must stop the app from starting - -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. - -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. - -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: - -```csharp -builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) - .ValidateDataAnnotations() // [Required] on each credential property - .ValidateOnStart(); // throws during startup, not on first request -``` - -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: - -```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) - throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); -``` - -Three rules for the message it fails with: - -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. - -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +Every operation on this API is public: each endpoint uses `NoneAuthScheme` and sends no credential. Construct the client and call them directly. ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- There are no credentials to set, and no secret to keep out of source. `AddDiscourseClient(options => ...)` still registers the client for DI — see `dotnet-client-initialization`. + diff --git a/plugins/discourse/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/discourse/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/discourse/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/discourse/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/discourse/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/discourse/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/discourse/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/discourse/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/discourse/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/discourse/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/discourse/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/discourse/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/discourse/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/discourse/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/discourse/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/discourse/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException
` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/discourse/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/discourse/skills/dotnet/dotnet-getting-started/SKILL.md index 04f26dae8..60c5c1fd7 100644 --- a/plugins/discourse/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/discourse/skills/dotnet/dotnet-getting-started/SKILL.md @@ -11,7 +11,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity @@ -26,7 +26,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any | Auth | None — this SDK generates no authentication | | Environments | `ServerEnvironment.Production` *(default)* → `https://{defaultHost}` | | Log environment variable | `DISCOURSECLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-discourse` makes that binding); the **Integration workflow** below says which governs which step. @@ -44,22 +44,24 @@ Models, enums, union types and the generated error types each sit in their own c ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Discourse, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/Discourse/Models/`, `src/Discourse/Errors/` and `src/Discourse/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/Discourse/Models/`, enums `src/Discourse/Models/Enums/`, typed errors `src/Discourse/Errors/`, request records `src/Discourse/Requests/`, or `src/Discourse/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: @@ -69,7 +71,7 @@ Keep lookups cheap — the rules that keep a session's context small: ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/discourse-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/discourse-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. **Clone once, up front**, into a fresh timestamped folder under `/discourse-sdk-src/`, and reuse that folder for the rest of your session: @@ -87,30 +89,31 @@ git clone --filter=blob:none --branch main https://github.com/context-plugins/di # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/discourse-sdk-src/`, **never** in the project repo, and its path never goes into `discourse-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `discourse-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `DiscourseClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/Discourse/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/Discourse/Models/` (+ `src/Discourse/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/Discourse/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/Discourse/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/Discourse/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/Discourse/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/Discourse/Servers/`, `src/Discourse/DiscourseClient.cs`, `src/Discourse/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Discourse may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything @@ -126,7 +129,7 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/Discourse/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step @@ -134,9 +137,9 @@ You loaded every companion the sheet named before starting (per `dotnet-integrat 1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new DiscourseClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) 2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `DiscourseClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/discourse/skills/dotnet/dotnet-integrate-discourse/SKILL.md b/plugins/discourse/skills/dotnet/dotnet-integrate-discourse/SKILL.md index fea228214..4bb4d1ad3 100644 --- a/plugins/discourse/skills/dotnet/dotnet-integrate-discourse/SKILL.md +++ b/plugins/discourse/skills/dotnet/dotnet-integrate-discourse/SKILL.md @@ -11,7 +11,7 @@ You are the Discourse .NET SDK specialist for this task — one agent for every ## Grounding rules -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `discourse-plan.md` — the plan must stay portable. +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `discourse-plan.md` never carries its path — the plan must stay portable. - **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. - **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. - **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. @@ -29,7 +29,7 @@ Your FIRST deliverable is `discourse-plan.md` at `/discourse- #### How to ground (map-first, one pass) 1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. 3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. 4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. 5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. @@ -38,7 +38,7 @@ Your FIRST deliverable is `discourse-plan.md` at `/discourse- These need no SDK knowledge and touch no project file, so do them before or alongside the map work: -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); - the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; - locating the SDK reference and its version in the project; @@ -54,10 +54,23 @@ Never use this prerequisite work as a "head start" on implementation: **creating #### The `discourse-plan.md` format (keep it tight — tables, not prose) 1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + 3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. 6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. 7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. @@ -67,12 +80,46 @@ Never use this prerequisite work as a "head start" on implementation: **creating | 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | | 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | | 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | | 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | | 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | | 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. @@ -95,14 +142,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. 3. After every change: `dotnet build`; fix non-SDK errors yourself. 4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `Discourse.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. ### Step 4 — Fixing SDK errors (map-first, in place) A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `Discourse.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/Discourse/Models/{Type}.cs`, `src/Discourse/Models/Enums/{Type}.cs`, `src/Discourse/Errors/{Type}.cs`, `src/Discourse/Requests/{Type}.cs`, or `src/Discourse/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. 3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". 4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). 5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. @@ -120,6 +168,6 @@ A standalone Discourse question with no code change: load `dotnet-getting-starte - **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. - **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. - **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search Discourse for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `Discourse` is what the generated source declares, not what public docs describe. +- **Do not web-search Discourse for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `Discourse` is what the generated source declares, not what public docs describe. - **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. diff --git a/plugins/discourse/skills/dotnet/dotnet-models/SKILL.md b/plugins/discourse/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/discourse/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/discourse/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/discourse/skills/dotnet/dotnet-models/reference.md b/plugins/discourse/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/discourse/skills/dotnet/dotnet-models/reference.md +++ b/plugins/discourse/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/discourse/skills/dotnet/dotnet-testing/SKILL.md b/plugins/discourse/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/discourse/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/discourse/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/discourse/skills/python/python-authentication/SKILL.md b/plugins/discourse/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/discourse/skills/python/python-authentication/SKILL.md +++ b/plugins/discourse/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/discourse/skills/python/python-calling-endpoints/SKILL.md b/plugins/discourse/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/discourse/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/discourse/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/discourse/skills/python/python-client-initialization/SKILL.md b/plugins/discourse/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/discourse/skills/python/python-client-initialization/SKILL.md +++ b/plugins/discourse/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/discourse/skills/python/python-configuration-resilience/SKILL.md b/plugins/discourse/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/discourse/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/discourse/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/discourse/skills/python/python-error-handling/SKILL.md b/plugins/discourse/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/discourse/skills/python/python-error-handling/SKILL.md +++ b/plugins/discourse/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/discourse/skills/python/python-event-streams/SKILL.md b/plugins/discourse/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/discourse/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/discourse/skills/python/python-file-handling/SKILL.md b/plugins/discourse/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/discourse/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/discourse/skills/python/python-getting-started/SKILL.md b/plugins/discourse/skills/python/python-getting-started/SKILL.md index f54453b4d..843327ff7 100644 --- a/plugins/discourse/skills/python/python-getting-started/SKILL.md +++ b/plugins/discourse/skills/python/python-getting-started/SKILL.md @@ -25,12 +25,12 @@ Verified against `discourse/` and `pyproject.toml` of the generated package at v | Version | `0.1.0` | | Sync client class | `DiscourseClient` (alias `Client`) | | Async client class | `AsyncDiscourseClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`), and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`) · `retry_options`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | None — the spec declares no security scheme | | Environments | **no environment enum** — one `base_url` string, defaulting to `https://{defaultHost}` | | Base-URL config | `ServerConfig` (`discourse/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | | Surface | 110 operations across 16 controllers · 250 models · 0 unions · 18 enums · 0 per-operation error unions | @@ -39,13 +39,18 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash pip install "discourse @ git+https://github.com/context-plugins/discourse-python-sdk.git@main" +python -c "import discourse, pathlib; print(pathlib.Path(discourse.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules @@ -69,7 +74,7 @@ Everything else comes from its own subpackage, and the split matters because eac | --- | --- | | Domain models, their `…Dict` companions | `discourse.models` | | Enums (and their open `…OrStr` aliases) | `discourse.models.enums` | -| `ApiError` · `RawError` · `ApiResult` · `RequestOptions` · `HttpClient` · `SdkBaseModel` · `UNSET` · `Optional` | `discourse.core` | +| `ApiError` · `RawError` · `ApiResult` · `RequestOptions` · `HttpClient` · `SdkBaseModel` · `UNSET` · `Optional` · `FileInput` · `AsyncFileInput` · `NamedFile` · `BinaryContent` · `AsyncBinaryContent` · `BinaryReader` · `AsyncBinaryReader` | `discourse.core` | | Per-operation error *unions* | `discourse.errors` — *this SDK declares none* | `discourse.core` re-exports its whole public surface (a curated `__all__`), so import from `…core` rather than from the private modules beneath it (`…core.results`, `…core.exceptions`, `…core.auth.schemes`). @@ -124,15 +129,15 @@ The SDK map's controller table carries the same counts and links to a page per c The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `discourse/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `discourse/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `discourse/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/discourse-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. Every `Source` path on the map is relative to that SDK root, so `discourse/models/access_control.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. @@ -152,7 +157,7 @@ Read the one module that owns the fact **inside the installed package**. Locate python -c "import discourse, pathlib; print(pathlib.Path(discourse.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/discourse` on Windows, `.venv/lib/python3.*/site-packages/discourse` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/discourse` on Windows, `.venv/lib/python3.*/site-packages/discourse` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -160,7 +165,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/di | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -169,8 +174,9 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/di | An enum's members and wire values | `models/enums/` | | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | +| What a file or binary parameter accepts, and the reader protocols behind it (the aliases are in the import table above; not in the map's Type sources) | `core/files.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | **Read scoped.** These modules carry long design docstrings; `grep -n` for the symbol and read the surrounding lines rather than whole files. Never quote a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. @@ -185,13 +191,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. This SDK declares no scheme, so there is no credentials keyword to set. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; Load secrets from the environment or a secret store, never hardcode.) -3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; **11 operations return `None`**, so `with_raw_response` is the only way to observe their status code; and the two response modes — raising vs `ApiResult` — behave differently on failure.) +3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; **11 operations return `None`**, so `with_raw_response` is the only way to observe their status code; a multipart body is **one flat parameter list** — every field, JSON part and file is its own parameter, and an optional part is omitted with `None` rather than by a second call shape; and the two response modes — raising vs `ApiResult` — behave differently on failure.) (*For the file positions:* load **python-file-handling** before you pass anything but a `Path` — a file position accepts far more than `bytes`, **never a `str`**, and nothing validates what you hand it.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`), so an unknown wire value passes through as a plain `str` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -199,11 +205,13 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 1. **Sync or async** — which client class, and the reminder that the two do not mix. Plus the `close()`/`aclose()` obligation and where the client is held. 2. **The keyword-only boundary** for each operation: what is positional (path params, sometimes the body) and what sits after `*`. Every keyword-only parameter has a real default, so there is no "must pass `None` explicitly" hazard — say so, so nobody writes defensive `None`s. -3. **The 11 operations that return `None`** — `backups.download_backup` · `backups.send_download_backup_email` · `badges.delete_badge` · `discourse_calendar_events.export_events_ics` · `posts.delete_post` · `topics.bookmark_topic` · `topics.get_topic_by_external_id` · `topics.remove_topic` · `users.change_password` · `users.update_email` · `users.update_username`. Their raw peers are `ApiResult[None, …]`, so `with_raw_response` is the only way to observe the status code. -4. **Required vs `UNSET`** for every model member the task sets, and the fact that `Optional[T]` here is `T | UnsetType` — **not** `typing.Optional`, so `None` is not a legal value for it. -5. **The `ApiError.error` union** for each operation in scope — no operation in this SDK documents a typed error body, so `.error` is always `RawError`. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 77 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which host the `base_url` selects**, because omitting it is silently the default. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +3. **The file positions** — `uploads.create_upload`, each annotated `FileInput` on the sync client and `AsyncFileInput` on the async one, with `list[…]` for an array part. What those admit is wider than the annotation shows and **never a `str`**, and the arm you pick decides what stays resident, who closes your handle and what the file is called on the wire — so the sheet cites **python-file-handling** for the row rather than restating it. +4. **The 11 operations that return `None`** — `backups.download_backup` · `backups.send_download_backup_email` · `badges.delete_badge` · `discourse_calendar_events.export_events_ics` · `posts.delete_post` · `topics.bookmark_topic` · `topics.get_topic_by_external_id` · `topics.remove_topic` · `users.change_password` · `users.update_email` · `users.update_username`. Their raw peers are `ApiResult[None, …]`, so `with_raw_response` is the only way to observe the status code. +5. **Required vs `UNSET`** for every model member the task sets, and the fact that `Optional[T]` here is `T | UnsetType` — **not** `typing.Optional`, so `None` is not a legal value for it. +6. **The `ApiError.error` union** for each operation in scope — no operation in this SDK documents a typed error body, so `.error` is always `RawError`. +7. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 77 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +8. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +9. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +10. **Which host the `base_url` selects**, because omitting it is silently the default. +11. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/discourse/skills/python/python-integrate-discourse/SKILL.md b/plugins/discourse/skills/python/python-integrate-discourse/SKILL.md index b4f35b34b..8c5a77d09 100644 --- a/plugins/discourse/skills/python/python-integrate-discourse/SKILL.md +++ b/plugins/discourse/skills/python/python-integrate-discourse/SKILL.md @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `python-getting-started` is your **lookup layer - **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import discourse, pathlib; print(pathlib.Path(discourse.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import discourse, pathlib; print(pathlib.Path(discourse.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) - **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. @@ -40,6 +40,21 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** creating or activating a virtual environment, installing `discourse` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. @@ -62,8 +77,9 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. 4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `discourse.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `discourse.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. ### Step 3 — Answering pure questions diff --git a/plugins/discourse/skills/python/python-models/SKILL.md b/plugins/discourse/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/discourse/skills/python/python-models/SKILL.md +++ b/plugins/discourse/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/discourse/skills/python/python-models/reference.md b/plugins/discourse/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/discourse/skills/python/python-models/reference.md +++ b/plugins/discourse/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/discourse/skills/python/python-testing/SKILL.md b/plugins/discourse/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/discourse/skills/python/python-testing/SKILL.md +++ b/plugins/discourse/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/discourse/skills/typescript/typescript-authentication/SKILL.md b/plugins/discourse/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/discourse/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/discourse/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/discourse/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/discourse/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/discourse/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/discourse/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/discourse/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/discourse/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/discourse/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/discourse/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/discourse/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/discourse/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/discourse/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/discourse/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/discourse/skills/typescript/typescript-error-handling/SKILL.md b/plugins/discourse/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/discourse/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/discourse/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/discourse/skills/typescript/typescript-getting-started/SKILL.md b/plugins/discourse/skills/typescript/typescript-getting-started/SKILL.md index 6568ae5a1..6b47fd8dd 100644 --- a/plugins/discourse/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/discourse/skills/typescript/typescript-getting-started/SKILL.md @@ -13,22 +13,24 @@ This is the **SDK-specific** entry point. For general patterns that apply to any ## SDK identity -Verified against `package.json` and `sdk-map.md` of the generated package at version `latest`. **Re-verify after a version bump** — this page is a snapshot, not a live read. +Verified against `package.json` and `sdk-map.md` of the generated package at version `0.1.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. | Fact | Value | | --- | --- | | API | Discourse | | Package name (what you install, and what you import) | `discourse` — **not on npm**; built from source (see *Install*) | | Import specifier | `discourse` — the package root is the **only** entry; deep imports do not resolve | -| Version | `latest` (API spec version `latest`) | +| Source repository | https://github.com/context-plugins/discourse-typescript-sdk (branch `main` — the ref this map documents) | +| Version | `0.1.0` (API spec version `latest`) | | Client class | `DiscourseClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new DiscourseClient(clientOptions: Partial = {})` — **every** field is optional, so `new DiscourseClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch`. `timeout` defaults to `60_000` ms | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new DiscourseClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new DiscourseClient()` compiles. Fields: `serverOptions` · `retry` · `fetch`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | None — the spec declares no security schemes | -| Environments | 1 environment (`ServerEnvironment.Production` *(default)*) × 1 server group | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`), defaulting to `https://{defaultHost}` | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Environments | no selector — one environment, so none is emitted; 1 server group | +| Base-URL config | `serverOptions.baseUrl` (`src/servers.ts`), defaulting to `https://{defaultHost}` | +| Log environment variable | `DISCOURSE_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Surface | 110 operations · 16 resources · 250 models · 18 open enums · 1 per-operation error subclass | @@ -37,22 +39,22 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/discourse-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 268 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, 268 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { DiscourseClient, ServerEnvironment, ResponseError, DiscourseError } from "discourse"; +import { DiscourseClient, ApiError, DiscourseError } from "discourse"; import type { ClientOptions, AccessControl } from "discourse"; ``` @@ -65,21 +67,19 @@ Things the specifier alone will not tell you: Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, every model type) must be imported with `import type`. Under `exactOptionalPropertyTypes`, **omit or spread** an absent optional field rather than assigning `undefined` to it. -## Environments +## Hosts -`ClientOptions.serverEnvironment` selects one environment for the whole client (`src/servers.ts`). `ServerEnvironment` is a `const` object with a derived union type — not a TypeScript `enum` — and unlike the model enums it is **closed**, so only its declared members are assignable. +This API declares one environment, so the SDK has **no environment selector** — there is no `ServerEnvironment` type and no field to set (`src/servers.ts`). The host below is the only one the SDK knows, and the override in the last column is the only way to reach another. -| Group | Environment | Base URL | Override at | -| --- | --- | --- | --- | -| `default` | `production` *(default)* | `https://{defaultHost}` | `serverOptions.default.production.baseUrl` | +| Group | Base URL | Override at | +| --- | --- | --- | +| `default` | `https://{defaultHost}` | `serverOptions.baseUrl` | Consequences to state on every contract sheet that touches configuration: -- Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- ⚠ **This SDK declares exactly one environment**, so reaching any other host is a `serverOptions` override, not an environment value — `serverOptions: { default: { production: { baseUrl: "…" } } }`. Note the key is still `production`: the environment name and the host it points at are now decoupled, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the deployment's intent explicit where the client is built, and verify it. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- ⚠ **This SDK declares exactly one environment**, so it emits no `ServerEnvironment` at all and reaching any other host is a `serverOptions` base-URL override — `serverOptions: { baseUrl: "…" }`. Nothing in the options then records **which** deployment this is, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the intent explicit where the client is built, and verify it. +- An override merges with the built-in default **key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. ## Auth pattern (no schemes) @@ -108,7 +108,7 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | `client.users` | `Users` | 25 | `activateUser` · `adminGetUser` · `adminListUsers` · `adminListUsersFlag` · `anonymizeUser` · `changePassword` · `createUser` · `deactivateUser` · `deleteUser` · `getUser` · `getUserEmails` · `getUserExternalId` · `getUserIdentiyProviderExternalId` · `listUserActions` · `listUserBadges` · `listUsersPublic` · `logOutUser` · `refreshGravatar` · `sendPasswordResetEmail` · `silenceUser` · `suspendUser` · `updateAvatar` · `updateEmail` · `updateUser` · `updateUsername` | | `client.admin` | `Admin` | 11 | `activateUser` · `adminGetUser` · `adminListUsers` · `adminListUsersFlag` · `anonymizeUser` · `deactivateUser` · `deleteUser` · `logOutUser` · `refreshGravatar` · `silenceUser` · `suspendUser` | -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** A name that collides inside its namespace is promoted to `RequestParams` instead — no operation in this SDK takes that spelling at this version. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -116,7 +116,7 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: @@ -125,11 +125,11 @@ Locate the installed package before you rely on a lookup: node -e "console.log(require.resolve('discourse/package.json'))" ``` -Failing that it is at `node_modules/discourse/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/discourse/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/discourse/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -137,7 +137,7 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/discourse/`: @@ -147,13 +147,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | *no scheme is declared, so no auth module is emitted* | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -167,30 +169,38 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `DISCOURSE_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new DiscourseClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new DiscourseClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) 2. **Authentication** — load **typescript-authentication** before you set credentials. This SDK declares no scheme, so there is nothing to set. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **11 operations resolve to `undefined`**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `DiscourseError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `DiscourseError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `DISCOURSE_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK Beyond the usual signatures and model members, a contract sheet for the Discourse TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. -1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment, `ServerEnvironment.Production`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. +1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment and emits no `ServerEnvironment`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. 2. **11 operations resolve to `undefined`** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 1 of 110 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. **That `Event` imported from this package is a model type, not the global of that name** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `DiscourseError` for the same reason. -10. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 1 of 110 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. **That `Event` imported from this package is a model type, not the global of that name** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `DiscourseError` for the same reason. +12. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/discourse/skills/typescript/typescript-integrate-discourse/SKILL.md b/plugins/discourse/skills/typescript/typescript-integrate-discourse/SKILL.md index 73c271f1a..26ff8c2ee 100644 --- a/plugins/discourse/skills/typescript/typescript-integrate-discourse/SKILL.md +++ b/plugins/discourse/skills/typescript/typescript-integrate-discourse/SKILL.md @@ -1,6 +1,6 @@ --- name: "typescript-integrate-discourse" -description: "MANDATORY FIRST STEP for Discourse TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Discourse in TypeScript/Node — export events ics, list events, create backup, download backup, get backups, send download backup email, or when the Discourse TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `discourse` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (discourse-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares only ServerEnvironment.Production, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." +description: "MANDATORY FIRST STEP for Discourse TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Discourse in TypeScript/Node — export events ics, list events, create backup, download backup, get backups, send download backup email, or when the Discourse TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `discourse` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (discourse-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares one environment and no ServerEnvironment, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." --- # Discourse TypeScript SDK — Integration workflow (lookup layer + contract sheet) @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/discourse/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('discourse/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('discourse/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. @@ -23,14 +23,14 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/discourse-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/discourse-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, **module format — ESM or CJS, which changes how the SDK is imported and whether `instanceof` on its error classes is trustworthy**, and the runtime: Node, a bundled browser app, or an edge runtime) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - **establish the toolchain before you need it**: the package manager, whether `discourse` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; - a baseline run of the project's checks (`tsc --noEmit`, the test runner, the linter — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment (`ServerEnvironment.Production`), so **any other host is a `serverOptions` base-URL override rather than an environment value**; +- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment, so it emits no `ServerEnvironment` at all and **any other host is a `serverOptions` base-URL override rather than an environment value**; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your credential may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; - setting up your task tracking. @@ -40,11 +40,85 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** installing `discourse` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. @@ -58,13 +132,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp ### Step 2 — Implement from the contract sheet 1. Read `discourse-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment — `ServerEnvironment.Production` — so a different host is a `serverOptions` override under a key still named after `production`: the environment name and the host it points at are decoupled, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. +2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment and emits no selector for it, so a different host is a `serverOptions` base-URL override. Nothing in the options then records **which** deployment this is, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. 3. **Build the client once and hold it.** Resources are memoized lazy getters on it, so rebuilding one per request throws that state away. There is no `close()` — it owns a `fetch`, not a pool. 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from the **11 operations that resolve to `undefined`** (every operation whose success declares no body — the map's **Returns** bullet names them). `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `DiscourseError` family, told apart by `err.kind`. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `DISCOURSE_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions @@ -74,7 +150,7 @@ A standalone Discourse question with no code change: look it up in `typescript-g - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. - **Never write any Discourse or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. - **Don't web-search Discourse topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Discourse docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. diff --git a/plugins/discourse/skills/typescript/typescript-models/SKILL.md b/plugins/discourse/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/discourse/skills/typescript/typescript-models/SKILL.md +++ b/plugins/discourse/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/discourse/skills/typescript/typescript-models/reference.md b/plugins/discourse/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/discourse/skills/typescript/typescript-models/reference.md +++ b/plugins/discourse/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/discourse/skills/typescript/typescript-testing/SKILL.md b/plugins/discourse/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/discourse/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/discourse/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/finnhub/.claude-plugin/plugin.json b/plugins/finnhub/.claude-plugin/plugin.json index 1b9ec9cb9..5a19a3bb6 100644 --- a/plugins/finnhub/.claude-plugin/plugin.json +++ b/plugins/finnhub/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "finnhub", "displayName": "Finnhub API", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Finnhub API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/finnhub/.cursor-plugin/plugin.json b/plugins/finnhub/.cursor-plugin/plugin.json index 1b9ec9cb9..5a19a3bb6 100644 --- a/plugins/finnhub/.cursor-plugin/plugin.json +++ b/plugins/finnhub/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "finnhub", "displayName": "Finnhub API", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Finnhub API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/finnhub/plugin.json b/plugins/finnhub/plugin.json index 1b9ec9cb9..5a19a3bb6 100644 --- a/plugins/finnhub/plugin.json +++ b/plugins/finnhub/plugin.json @@ -1,7 +1,7 @@ { "name": "finnhub", "displayName": "Finnhub API", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Finnhub API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/finnhub/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/finnhub/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..3d580e7c4 100644 --- a/plugins/finnhub/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/finnhub/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,245 +1,67 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Finnhub API .NET SDK in C# — this API authenticates with an API key in a query parameter, and this skill gives the credential each scheme takes, the FinnhubApiClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Finnhub API .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with an API key in a query parameter. APIMatic surfaces each scheme as a **nullable credentials property** on `FinnhubApiClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) - -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). - -## Basic auth - -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` - -Sends `Authorization: Basic base64(username:password)`. - -## Bearer token - -Set the configured token property on the options class to your access-token string: - -```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; -``` - -Sends `Authorization: Bearer ACCESS_TOKEN`. - -## API key (header, query, or cookie) - -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: - -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` - -## OAuth 2.0 — client credentials (machine-to-machine) - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; - -options.{OAuthProperty} = new OAuth2ClientCredentials -{ - ClientId = "...", - ClientSecret = "...", - Scope = "..." // optional -}; -``` - -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. - -## OAuth 2.0 — authorization code (3-legged, with PKCE) - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; - -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials -{ - ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE - RedirectUri = "https://app.example.com/callback", - Scope = "...", // optional - State = "...", // optional CSRF token - Pkce = PkceMethod.S256, // default; RFC 7636 - PromptForAuthorizationCode = async (authorizationUrl, ct) => - { - // Open/redirect the browser to authorizationUrl, then return the - // authorization code your redirect endpoint received. - return await GetCodeFromUserAsync(authorizationUrl, ct); - } -}; -``` - -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. - -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: - -| PKCE | token request built | no secret ⇒ | +| Scheme | Property on `FinnhubApiClientOptions` | Credential | | --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | +| API key — `token` query parameter | `ApiKey` | `string` | -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. +**That table is the entire authentication surface of this SDK.** `FinnhubApi.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. -## OAuth 2.0 — resource owner password +## API key (query parameter) -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; - -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` - -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. - -### Supplying your own token source - -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. +The key is appended to every request as the `token` query parameter. The parameter name is fixed by the generated scheme — you supply only the value: ```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) +options.ApiKey = "API_KEY"; ``` -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: - -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). - -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. - -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +⚠ **A query-string credential is logged by everything on the path.** Proxies, CDNs, load balancers and the provider's own access logs record full URLs, and this SDK's request logging is no exception. Treat the key as exposed to your infrastructure, and rotate it on that basis. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `FinnhubApiClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("FinnhubApiClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.ApiKey)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "FinnhubApiClient:ApiKey is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"FinnhubApiClient:ApiKey is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +Check every value the schemes actually need. For this SDK that is: + +| Value to validate | Feeds `FinnhubApiClientOptions` property | +| --- | --- | +| `ApiKey` | `ApiKey` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddFinnhubApiClient(options => ...)` callback when registering via DI. `FinnhubApiClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddFinnhubApiClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/finnhub/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/finnhub/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/finnhub/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/finnhub/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/finnhub/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/finnhub/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/finnhub/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/finnhub/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/finnhub/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/finnhub/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/finnhub/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/finnhub/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/finnhub/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/finnhub/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/finnhub/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/finnhub/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/finnhub/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/finnhub/skills/dotnet/dotnet-getting-started/SKILL.md index 740648e00..d30839b40 100644 --- a/plugins/finnhub/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/finnhub/skills/dotnet/dotnet-getting-started/SKILL.md @@ -1,17 +1,17 @@ --- name: "dotnet-getting-started" -description: "Finnhub API .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-finnhub is the workflow that uses it." +description: "Finnhub API .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-finnhub-api is the workflow that uses it." --- # Getting started with the Finnhub API .NET SDK -> **Who this skill is for.** This is the **map layer**: load it to ground every Finnhub API SDK fact. `dotnet-integrate-finnhub` is the workflow that drives it — you collect the contracts for the work in scope into `finnhub-api-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. +> **Who this skill is for.** This is the **map layer**: load it to ground every Finnhub API SDK fact. `dotnet-integrate-finnhub-api` is the workflow that drives it — you collect the contracts for the work in scope into `finnhub-api-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated .NET SDK (auth, calling endpoints, models, error handling, retries, testing), see the companion API-agnostic skills: `dotnet-client-initialization`, `dotnet-authentication`, `dotnet-calling-endpoints`, `dotnet-models`, `dotnet-error-handling`, `dotnet-configuration-resilience`, `dotnet-testing`. **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity @@ -26,9 +26,9 @@ This is the **SDK-specific** entry point. For general patterns that apply to any | Auth | **API key** in the `token` query parameter — set `ApiKey` | | Environments | `ServerEnvironment.Production` *(default)* → `https://finnhub.io/api/v1` | | Log environment variable | `FINNHUBAPICLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | -The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-finnhub` makes that binding); the **Integration workflow** below says which governs which step. +The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-finnhub-api` makes that binding); the **Integration workflow** below says which governs which step. ## Namespaces (using-directives) @@ -44,22 +44,24 @@ Models, enums, union types and the generated error types each sit in their own c ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Finnhub API, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/FinnhubApi/Models/`, `src/FinnhubApi/Errors/` and `src/FinnhubApi/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/FinnhubApi/Models/`, enums `src/FinnhubApi/Models/Enums/`, typed errors `src/FinnhubApi/Errors/`, request records `src/FinnhubApi/Requests/`, or `src/FinnhubApi/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: @@ -69,7 +71,7 @@ Keep lookups cheap — the rules that keep a session's context small: ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/finnhub-api-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/finnhub-api-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. **Clone once, up front**, into a fresh timestamped folder under `/finnhub-api-sdk-src/`, and reuse that folder for the rest of your session: @@ -87,30 +89,31 @@ git clone --filter=blob:none --branch main https://github.com/context-plugins/fi # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/finnhub-api-sdk-src/`, **never** in the project repo, and its path never goes into `finnhub-api-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `finnhub-api-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `FinnhubApiClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/FinnhubApi/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/FinnhubApi/Models/` (+ `src/FinnhubApi/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/FinnhubApi/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/FinnhubApi/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/FinnhubApi/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/FinnhubApi/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/FinnhubApi/Servers/`, `src/FinnhubApi/FinnhubApiClient.cs`, `src/FinnhubApi/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Finnhub API may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything @@ -126,17 +129,17 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/FinnhubApi/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step -You loaded every companion the sheet named before starting (per `dotnet-integrate-finnhub`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: +You loaded every companion the sheet named before starting (per `dotnet-integrate-finnhub-api`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: 1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new FinnhubApiClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) 2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `FinnhubApiClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/finnhub/skills/dotnet/dotnet-integrate-finnhub/SKILL.md b/plugins/finnhub/skills/dotnet/dotnet-integrate-finnhub-api/SKILL.md similarity index 65% rename from plugins/finnhub/skills/dotnet/dotnet-integrate-finnhub/SKILL.md rename to plugins/finnhub/skills/dotnet/dotnet-integrate-finnhub-api/SKILL.md index 01662b397..4f16e2821 100644 --- a/plugins/finnhub/skills/dotnet/dotnet-integrate-finnhub/SKILL.md +++ b/plugins/finnhub/skills/dotnet/dotnet-integrate-finnhub-api/SKILL.md @@ -1,125 +1,173 @@ ---- -name: "dotnet-integrate-finnhub" -description: "MANDATORY FIRST STEP for Finnhub API .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Finnhub API in C# — Bond, Calendar, CorporateActions, Crypto, Economic, Etf, Forex, GlobalFilings, IndexApi, Institutional, Misc, MutualFund, News, Scan, StockEstimates, StockFundamentals, StockOwnership, StockPrices — or when a Finnhub API .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file finnhub-api-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." ---- - -# Finnhub API .NET SDK — Workflow (map + skills) - -You are the Finnhub API .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. - -**Scope guard:** the APIMatic-generated Finnhub API **.NET SDK** (root namespace `FinnhubApi`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. - -## Grounding rules - -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `finnhub-api-plan.md` — the plan must stay portable. -- **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. -- **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. -- **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. -- **The sheet records Finnhub API's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. -- **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. - -## Workflow - -**If the user opens with a reported SDK error or unexpected Finnhub API behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: - -### Step 1 — Plan first: write `finnhub-api-plan.md` (always, for any implementation work) - -Your FIRST deliverable is `finnhub-api-plan.md` at `/finnhub-api-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. - -#### How to ground (map-first, one pass) - -1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. -3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. -4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. -5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. - -#### Prerequisites you can run alongside - -These need no SDK knowledge and touch no project file, so do them before or alongside the map work: - -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); -- the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; -- locating the SDK reference and its version in the project; -- credentials/environment verification (per the task's secret-handling rules); -- setting up your task tracking. - -Never use this prerequisite work as a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. - -#### HARD GATE - -**No project-file creation or edits until `finnhub-api-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. - -#### The `finnhub-api-plan.md` format (keep it tight — tables, not prose) - -1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. -3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. -6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. -7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. - -| # | Concern | The decision the plan must record | -| --- | --- | --- | -| 1 | **Credential fail-fast** | Where credentials are bound, and that the host refuses to start when one is missing or blank — rather than discovering it as a 401 on the first call in production. A multi-part credential needs **every** part checked, because a blank part is not a missing one. | -| 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | -| 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | -| 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | -| 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | -| 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | -| 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | - -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. - -Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. - -### Step 2 — Required reading (do this before you write any code) - -The contract sheet ends with a **REQUIRED READING** block, and its rows carry inline `MUST load ` pointers. **Load every `dotnet-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. If the sheet names none, load `dotnet-error-handling` anyway — every integration writes an error boundary. - -These are API-agnostic usage skills; loading them is not the same as reading the map. Contract *facts* still come only from the sheet or a map lookup. - -Before implementing, check the plan's **Assumptions & Blockers** section: - -- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `finnhub-api-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. -- Minor assumptions only → proceed. - -Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. - -### Step 3 — Implement from the contract sheet - -1. Read `finnhub-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. -2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. -3. After every change: `dotnet build`; fix non-SDK errors yourself. -4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `FinnhubApi.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. - -### Step 4 — Fixing SDK errors (map-first, in place) - -A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `FinnhubApi.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. - -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. -3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". -4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). -5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. -6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. - -If a fix corrects a row in `finnhub-api-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. - -### Step 5 — Pure questions - -A standalone Finnhub API question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. - -## Anti-patterns — never do these - -- **Never write a Finnhub API/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** -- **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. -- **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. -- **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search Finnhub API for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `FinnhubApi` is what the generated source declares, not what public docs describe. -- **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. - +--- +name: "dotnet-integrate-finnhub-api" +description: "MANDATORY FIRST STEP for Finnhub API .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Finnhub API in C# — Bond, Calendar, CorporateActions, Crypto, Economic, Etf, Forex, GlobalFilings, IndexApi, Institutional, Misc, MutualFund, News, Scan, StockEstimates, StockFundamentals, StockOwnership, StockPrices — or when a Finnhub API .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file finnhub-api-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." +--- + +# Finnhub API .NET SDK — Workflow (map + skills) + +You are the Finnhub API .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. + +**Scope guard:** the APIMatic-generated Finnhub API **.NET SDK** (root namespace `FinnhubApi`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. + +## Grounding rules + +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `finnhub-api-plan.md` never carries its path — the plan must stay portable. +- **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. +- **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. +- **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. +- **The sheet records Finnhub API's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. +- **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. + +## Workflow + +**If the user opens with a reported SDK error or unexpected Finnhub API behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: + +### Step 1 — Plan first: write `finnhub-api-plan.md` (always, for any implementation work) + +Your FIRST deliverable is `finnhub-api-plan.md` at `/finnhub-api-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. + +#### How to ground (map-first, one pass) + +1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. +3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. +4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. +5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. + +#### Prerequisites you can run alongside + +These need no SDK knowledge and touch no project file, so do them before or alongside the map work: + +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; +- `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; +- locating the SDK reference and its version in the project; +- credentials/environment verification (per the task's secret-handling rules); +- setting up your task tracking. + +Never use this prerequisite work as a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. + +#### HARD GATE + +**No project-file creation or edits until `finnhub-api-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. + +#### The `finnhub-api-plan.md` format (keep it tight — tables, not prose) + +1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + +3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. +7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. + +| # | Concern | The decision the plan must record | +| --- | --- | --- | +| 1 | **Credential fail-fast** | Where credentials are bound, and that the host refuses to start when one is missing or blank — rather than discovering it as a 401 on the first call in production. A multi-part credential needs **every** part checked, because a blank part is not a missing one. | +| 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | +| 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | +| 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | +| 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | +| 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. + +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. + +Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. + +### Step 2 — Required reading (do this before you write any code) + +The contract sheet ends with a **REQUIRED READING** block, and its rows carry inline `MUST load ` pointers. **Load every `dotnet-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. If the sheet names none, load `dotnet-error-handling` anyway — every integration writes an error boundary. + +These are API-agnostic usage skills; loading them is not the same as reading the map. Contract *facts* still come only from the sheet or a map lookup. + +Before implementing, check the plan's **Assumptions & Blockers** section: + +- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `finnhub-api-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. +- Minor assumptions only → proceed. + +Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. + +### Step 3 — Implement from the contract sheet + +1. Read `finnhub-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. +2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. +3. After every change: `dotnet build`; fix non-SDK errors yourself. +4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `FinnhubApi.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. + +### Step 4 — Fixing SDK errors (map-first, in place) + +A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `FinnhubApi.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. + +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/FinnhubApi/Models/{Type}.cs`, `src/FinnhubApi/Models/Enums/{Type}.cs`, `src/FinnhubApi/Errors/{Type}.cs`, `src/FinnhubApi/Requests/{Type}.cs`, or `src/FinnhubApi/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. +3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". +4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). +5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. +6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. + +If a fix corrects a row in `finnhub-api-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. + +### Step 5 — Pure questions + +A standalone Finnhub API question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. + +## Anti-patterns — never do these + +- **Never write a Finnhub API/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** +- **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. +- **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. +- **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. +- **Do not web-search Finnhub API for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `FinnhubApi` is what the generated source declares, not what public docs describe. +- **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. + diff --git a/plugins/finnhub/skills/dotnet/dotnet-models/SKILL.md b/plugins/finnhub/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/finnhub/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/finnhub/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/finnhub/skills/dotnet/dotnet-models/reference.md b/plugins/finnhub/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/finnhub/skills/dotnet/dotnet-models/reference.md +++ b/plugins/finnhub/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/finnhub/skills/dotnet/dotnet-testing/SKILL.md b/plugins/finnhub/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/finnhub/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/finnhub/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/finnhub/skills/python/python-authentication/SKILL.md b/plugins/finnhub/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/finnhub/skills/python/python-authentication/SKILL.md +++ b/plugins/finnhub/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/finnhub/skills/python/python-calling-endpoints/SKILL.md b/plugins/finnhub/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/finnhub/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/finnhub/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/finnhub/skills/python/python-client-initialization/SKILL.md b/plugins/finnhub/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/finnhub/skills/python/python-client-initialization/SKILL.md +++ b/plugins/finnhub/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/finnhub/skills/python/python-configuration-resilience/SKILL.md b/plugins/finnhub/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/finnhub/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/finnhub/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/finnhub/skills/python/python-error-handling/SKILL.md b/plugins/finnhub/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/finnhub/skills/python/python-error-handling/SKILL.md +++ b/plugins/finnhub/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/finnhub/skills/python/python-event-streams/SKILL.md b/plugins/finnhub/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/finnhub/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/finnhub/skills/python/python-file-handling/SKILL.md b/plugins/finnhub/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/finnhub/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/finnhub/skills/python/python-getting-started/SKILL.md b/plugins/finnhub/skills/python/python-getting-started/SKILL.md index 89ddbbc84..96a9c3a4f 100644 --- a/plugins/finnhub/skills/python/python-getting-started/SKILL.md +++ b/plugins/finnhub/skills/python/python-getting-started/SKILL.md @@ -5,7 +5,7 @@ description: "Finnhub API Python SDK identity and lookup layer (Python only) — # Getting started with the Finnhub API Python SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Finnhub API Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-finnhub` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Finnhub API Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-finnhub-api` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated Python SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `python-client-initialization`, `python-authentication`, `python-calling-endpoints`, `python-models`, `python-error-handling`, `python-configuration-resilience` and `python-testing`. @@ -25,12 +25,12 @@ Verified against `finnhub_api/` and `pyproject.toml` of the generated package at | Version | `1.0.0` | | Sync client class | `FinnhubApiClient` (alias `Client`) | | Async client class | `AsyncFinnhubApiClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`) · `api_key`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`) · `retry_options` · `api_key`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | **API key** in the `token` query parameter — set `api_key` | | Environments | **no environment enum** — one `base_url` string, defaulting to `https://finnhub.io/api/v1` | | Base-URL config | `ServerConfig` (`finnhub_api/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | | Surface | 117 operations across 18 controllers · 192 models · 0 unions · 0 enums · 0 per-operation error unions | @@ -39,13 +39,18 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash pip install "finnhub-api @ git+https://github.com/context-plugins/finnhub-python-sdk.git@main" +python -c "import finnhub_api, pathlib; print(pathlib.Path(finnhub_api.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across three modules @@ -135,15 +140,15 @@ The SDK map's controller table carries the same counts and links to a page per c The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `finnhub_api/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `finnhub_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `finnhub_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/finnhub-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. Every `Source` path on the map is relative to that SDK root, so `finnhub_api/models/aichat_body.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. @@ -163,7 +168,7 @@ Read the one module that owns the fact **inside the installed package**. Locate python -c "import finnhub_api, pathlib; print(pathlib.Path(finnhub_api.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/finnhub_api` on Windows, `.venv/lib/python3.*/site-packages/finnhub_api` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/finnhub_api` on Windows, `.venv/lib/python3.*/site-packages/finnhub_api` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -171,7 +176,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/fi | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -179,7 +184,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/fi | A model's members, required vs `UNSET`, wire aliases | `models/.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | @@ -195,13 +200,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The one scheme is `api_key=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; **1 operation returns `None`**, so `with_raw_response` is the only way to observe its status code; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -212,8 +217,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 3. **The 1 operation that returns `None`** — `global_filings.global_filings_download`. Its raw peer is `ApiResult[None, …]`, so `with_raw_response` is the only way to observe the status code. 4. **Required vs `UNSET`** for every model member the task sets, and the fact that `Optional[T]` here is `T | UnsetType` — **not** `typing.Optional`, so `None` is not a legal value for it. 5. **The `ApiError.error` union** for each operation in scope — no operation in this SDK documents a typed error body, so `.error` is always `RawError`. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares no required member on any return type, so a truncated body decodes without complaint and the hole surfaces later.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which host the `base_url` selects**, because omitting it is silently the default. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares no required member on any return type, so a truncated body decodes without complaint and the hole surfaces later.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which host the `base_url` selects**, because omitting it is silently the default. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/finnhub/skills/python/python-integrate-finnhub/SKILL.md b/plugins/finnhub/skills/python/python-integrate-finnhub-api/SKILL.md similarity index 81% rename from plugins/finnhub/skills/python/python-integrate-finnhub/SKILL.md rename to plugins/finnhub/skills/python/python-integrate-finnhub-api/SKILL.md index 2e4ef9a71..2b1513d89 100644 --- a/plugins/finnhub/skills/python/python-integrate-finnhub/SKILL.md +++ b/plugins/finnhub/skills/python/python-integrate-finnhub-api/SKILL.md @@ -1,81 +1,97 @@ ---- -name: "python-integrate-finnhub" -description: "MANDATORY FIRST STEP for Finnhub API Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Finnhub API in Python — get yield curve data for treasury bonds, get recent and upcoming ipo, get candlestick data for crypto symbols, list supported crypto exchanges, get crypto's profile, list supported crypto symbols by exchange, list codes of supported economic data, get economic data — or when a Finnhub API Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." ---- - -# Finnhub API Python SDK — Integration workflow (lookup layer + contract sheet) - -You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. - -## Your lookup layer - -- **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. -- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import finnhub_api, pathlib; print(pathlib.Path(finnhub_api.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) -- **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. -- **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. - -**Scope guard:** the APIMatic-generated Finnhub API **Python SDK** (import root `finnhub_api`, distribution `finnhub-api`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. - -## Workflow - -**If the user opens with a reported SDK error or unexpected Finnhub API behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: - -### Step 1 — Plan first (always, for any implementation work) - -Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/finnhub-api-plan.md` — that absolute path, not a location you pick later. - -**Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - -- the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `finnhub-api` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; -- a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — including which host `base_url` will select, since omitting it is silent; -- **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; -- setting up your task tracking. - -Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. - -**HARD GATE — no project-file creation or edits until:** `finnhub-api-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. - -**What the gate does not cover:** creating or activating a virtual environment, installing `finnhub-api` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. - -### Step 1c — Required reading (do this before you write any code) - -End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. - -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `python-error-handling` always (every integration writes an error boundary); `python-client-initialization` before the client is constructed; `python-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes the transport. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. - -These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a module in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. - -Before implementing, check the plan's **Assumptions & Blockers** section: - -- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `finnhub-api-plan.md` in place. -- Minor assumptions only → proceed. - -Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. - -### Step 2 — Implement from the contract sheet - -1. Read `finnhub-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). -3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. -4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. -5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `finnhub_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. - -### Step 3 — Answering pure questions - -A standalone Finnhub API question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. - -## Anti-patterns — never do these - -- **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. -- **Never write a Finnhub API/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Don't web-search Finnhub API topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Finnhub API docs describe the REST API, not this SDK's generated surface, and the two disagree on names. -- **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. -- **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. -- **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. -- **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. - +--- +name: "python-integrate-finnhub-api" +description: "MANDATORY FIRST STEP for Finnhub API Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Finnhub API in Python — get yield curve data for treasury bonds, get recent and upcoming ipo, get candlestick data for crypto symbols, list supported crypto exchanges, get crypto's profile, list supported crypto symbols by exchange, list codes of supported economic data, get economic data — or when a Finnhub API Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." +--- + +# Finnhub API Python SDK — Integration workflow (lookup layer + contract sheet) + +You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. + +## Your lookup layer + +- **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. +- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import finnhub_api, pathlib; print(pathlib.Path(finnhub_api.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. +- **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. + +**Scope guard:** the APIMatic-generated Finnhub API **Python SDK** (import root `finnhub_api`, distribution `finnhub-api`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. + +## Workflow + +**If the user opens with a reported SDK error or unexpected Finnhub API behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: + +### Step 1 — Plan first (always, for any implementation work) + +Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/finnhub-api-plan.md` — that absolute path, not a location you pick later. + +**Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** + +- the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; +- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `finnhub-api` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; +- a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; +- credentials/environment verification (per the task's secret-handling rules) — including which host `base_url` will select, since omitting it is silent; +- **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; +- setting up your task tracking. + +Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. + +**HARD GATE — no project-file creation or edits until:** `finnhub-api-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. + +**What the gate does not cover:** creating or activating a virtual environment, installing `finnhub-api` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +### Step 1c — Required reading (do this before you write any code) + +End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. + +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `python-error-handling` always (every integration writes an error boundary); `python-client-initialization` before the client is constructed; `python-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes the transport. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. + +These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a module in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. + +Before implementing, check the plan's **Assumptions & Blockers** section: + +- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `finnhub-api-plan.md` in place. +- Minor assumptions only → proceed. + +Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. + +### Step 2 — Implement from the contract sheet + +1. Read `finnhub-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. +2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). +3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. +4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. +5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `finnhub_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. + +### Step 3 — Answering pure questions + +A standalone Finnhub API question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. + +## Anti-patterns — never do these + +- **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. +- **Never write a Finnhub API/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** +- **Don't web-search Finnhub API topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Finnhub API docs describe the REST API, not this SDK's generated surface, and the two disagree on names. +- **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. +- **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. +- **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. +- **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. + diff --git a/plugins/finnhub/skills/python/python-models/SKILL.md b/plugins/finnhub/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/finnhub/skills/python/python-models/SKILL.md +++ b/plugins/finnhub/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/finnhub/skills/python/python-models/reference.md b/plugins/finnhub/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/finnhub/skills/python/python-models/reference.md +++ b/plugins/finnhub/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/finnhub/skills/python/python-testing/SKILL.md b/plugins/finnhub/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/finnhub/skills/python/python-testing/SKILL.md +++ b/plugins/finnhub/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/finnhub/skills/typescript/typescript-authentication/SKILL.md b/plugins/finnhub/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/finnhub/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/finnhub/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/finnhub/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/finnhub/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/finnhub/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/finnhub/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/finnhub/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/finnhub/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/finnhub/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/finnhub/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/finnhub/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/finnhub/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/finnhub/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/finnhub/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/finnhub/skills/typescript/typescript-error-handling/SKILL.md b/plugins/finnhub/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/finnhub/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/finnhub/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/finnhub/skills/typescript/typescript-getting-started/SKILL.md b/plugins/finnhub/skills/typescript/typescript-getting-started/SKILL.md index 79d835e32..90146304b 100644 --- a/plugins/finnhub/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/finnhub/skills/typescript/typescript-getting-started/SKILL.md @@ -5,7 +5,7 @@ description: "Finnhub API TypeScript SDK identity and lookup layer (TypeScript/J # Getting started with the Finnhub API TypeScript SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Finnhub API TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-finnhub` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Finnhub API TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-finnhub-api` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated TypeScript SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `typescript-client-initialization`, `typescript-authentication`, `typescript-calling-endpoints`, `typescript-models`, `typescript-error-handling`, `typescript-configuration-resilience` and `typescript-testing`. @@ -20,15 +20,17 @@ Verified against `package.json` and `sdk-map.md` of the generated package at ver | API | Finnhub API | | Package name (what you install, and what you import) | `finnhub-api` — **not on npm**; built from source (see *Install*) | | Import specifier | `finnhub-api` — the package root is the **only** entry; deep imports do not resolve | +| Source repository | https://github.com/context-plugins/finnhub-typescript-sdk (branch `main` — the ref this map documents) | | Version | `1.0.0` (API spec version `1.0.0`) | | Client class | `FinnhubApiClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new FinnhubApiClient(clientOptions: Partial = {})` — **every** field is optional, so `new FinnhubApiClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `apiKey`. `timeout` defaults to `60_000` ms | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new FinnhubApiClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new FinnhubApiClient()` compiles. Fields: `serverOptions` · `retry` · `fetch` · `apiKey`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **API key (query)** — set `ClientOptions.apiKey` | -| Environments | 1 environment (`ServerEnvironment.Production` *(default)*) × 1 server group | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`), defaulting to `https://finnhub.io/api/v1` | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Environments | no selector — one environment, so none is emitted; 1 server group | +| Base-URL config | `serverOptions.baseUrl` (`src/servers.ts`), defaulting to `https://finnhub.io/api/v1` | +| Log environment variable | `FINNHUB_API_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Surface | 117 operations · 18 resources · 192 models · 0 per-operation error subclasses | @@ -37,22 +39,22 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/finnhub-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 192 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, 192 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { FinnhubApiClient, ServerEnvironment, ResponseError, FinnhubApiError } from "finnhub-api"; +import { FinnhubApiClient, ApiError, FinnhubApiError } from "finnhub-api"; import type { ClientOptions, AiChatBody } from "finnhub-api"; ``` @@ -64,21 +66,19 @@ Things the specifier alone will not tell you: Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, every model type) must be imported with `import type`. Under `exactOptionalPropertyTypes`, **omit or spread** an absent optional field rather than assigning `undefined` to it. -## Environments +## Hosts -`ClientOptions.serverEnvironment` selects one environment for the whole client (`src/servers.ts`). `ServerEnvironment` is a `const` object with a derived union type — not a TypeScript `enum` — and unlike the model enums it is **closed**, so only its declared members are assignable. +This API declares one environment, so the SDK has **no environment selector** — there is no `ServerEnvironment` type and no field to set (`src/servers.ts`). The host below is the only one the SDK knows, and the override in the last column is the only way to reach another. -| Group | Environment | Base URL | Override at | -| --- | --- | --- | --- | -| `default` | `production` *(default)* | `https://finnhub.io/api/v1` | `serverOptions.default.production.baseUrl` | +| Group | Base URL | Override at | +| --- | --- | --- | +| `default` | `https://finnhub.io/api/v1` | `serverOptions.baseUrl` | Consequences to state on every contract sheet that touches configuration: -- Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- ⚠ **This SDK declares exactly one environment**, so reaching any other host is a `serverOptions` override, not an environment value — `serverOptions: { default: { production: { baseUrl: "…" } } }`. Note the key is still `production`: the environment name and the host it points at are now decoupled, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the deployment's intent explicit where the client is built, and verify it. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- ⚠ **This SDK declares exactly one environment**, so it emits no `ServerEnvironment` at all and reaching any other host is a `serverOptions` base-URL override — `serverOptions: { baseUrl: "…" }`. Nothing in the options then records **which** deployment this is, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the intent explicit where the client is built, and verify it. +- An override merges with the built-in default **key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. ## Auth pattern (1 scheme) @@ -100,7 +100,7 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. See `typescript-authentication` for the full picture. @@ -129,7 +129,7 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | `client.stockOwnership` | `StockOwnership` | 12 | `congressionalTrading` · `fundOwnership` · `insiderSentiment` · `insiderTransactions` · `investmentThemes` · `ownership` · `socialSentiment` · `stockLobbying` · `stockUsaSpending` · `stockUsptoPatent` · `stockVisaApplication` · `supplyChainRelationships` | | `client.stockPrices` | `StockPrices` | 13 | `companyPeers` · `historicalMarketCap` · `marketHoliday` · `marketStatus` · `priceMetrics` · `stockBasicDividends` · `stockBidask` · `stockCandles` · `stockDividends` · `stockNbbo` · `stockSplits` · `stockSymbols` · `stockTick` | -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** A name that collides inside its namespace is promoted to `RequestParams` instead — no operation in this SDK takes that spelling at this version. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -137,7 +137,7 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: @@ -146,11 +146,11 @@ Locate the installed package before you rely on a lookup: node -e "console.log(require.resolve('finnhub-api/package.json'))" ``` -Failing that it is at `node_modules/finnhub-api/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/finnhub-api/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/finnhub-api/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -158,7 +158,7 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/finnhub-api/`: @@ -168,13 +168,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -188,29 +190,37 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `FINNHUB_API_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new FinnhubApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new FinnhubApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) 2. **Authentication** — load **typescript-authentication** before you set credentials. The scheme is `apiKey` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **One operation resolves to `undefined` (`globalFilings.globalFilingsDownload`)**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `FinnhubApiError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `FinnhubApiError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `FINNHUB_API_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK Beyond the usual signatures and model members, a contract sheet for the Finnhub API TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. -1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment, `ServerEnvironment.Production`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. +1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment and emits no `ServerEnvironment`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. 2. **One operation resolves to `undefined` (`globalFilings.globalFilingsDownload`)** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. No operation declares a typed error body, so all 117 reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. No operation declares a typed error body, so all 117 reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/finnhub/skills/typescript/typescript-integrate-finnhub/SKILL.md b/plugins/finnhub/skills/typescript/typescript-integrate-finnhub-api/SKILL.md similarity index 51% rename from plugins/finnhub/skills/typescript/typescript-integrate-finnhub/SKILL.md rename to plugins/finnhub/skills/typescript/typescript-integrate-finnhub-api/SKILL.md index b800b2163..9ad20c385 100644 --- a/plugins/finnhub/skills/typescript/typescript-integrate-finnhub/SKILL.md +++ b/plugins/finnhub/skills/typescript/typescript-integrate-finnhub-api/SKILL.md @@ -1,6 +1,6 @@ --- -name: "typescript-integrate-finnhub" -description: "MANDATORY FIRST STEP for Finnhub API TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Finnhub API in TypeScript/Node — bond price, bond profile, bond tick, bond yield curve, earnings calendar, economic calendar, or when the Finnhub API TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `finnhub-api` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (finnhub-api-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares only ServerEnvironment.Production, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." +name: "typescript-integrate-finnhub-api" +description: "MANDATORY FIRST STEP for Finnhub API TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Finnhub API in TypeScript/Node — bond price, bond profile, bond tick, bond yield curve, earnings calendar, economic calendar, or when the Finnhub API TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `finnhub-api` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (finnhub-api-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares one environment and no ServerEnvironment, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." --- # Finnhub API TypeScript SDK — Integration workflow (lookup layer + contract sheet) @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/finnhub-api/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('finnhub-api/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('finnhub-api/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. @@ -23,14 +23,14 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/finnhub-api-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/finnhub-api-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, **module format — ESM or CJS, which changes how the SDK is imported and whether `instanceof` on its error classes is trustworthy**, and the runtime: Node, a bundled browser app, or an edge runtime) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - **establish the toolchain before you need it**: the package manager, whether `finnhub-api` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; - a baseline run of the project's checks (`tsc --noEmit`, the test runner, the linter — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment (`ServerEnvironment.Production`), so **any other host is a `serverOptions` base-URL override rather than an environment value**; +- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment, so it emits no `ServerEnvironment` at all and **any other host is a `serverOptions` base-URL override rather than an environment value**; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your credential may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; - setting up your task tracking. @@ -40,11 +40,85 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** installing `finnhub-api` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. @@ -58,13 +132,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp ### Step 2 — Implement from the contract sheet 1. Read `finnhub-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment — `ServerEnvironment.Production` — so a different host is a `serverOptions` override under a key still named after `production`: the environment name and the host it points at are decoupled, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. +2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment and emits no selector for it, so a different host is a `serverOptions` base-URL override. Nothing in the options then records **which** deployment this is, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. 3. **Build the client once and hold it.** Resources are memoized lazy getters on it, so rebuilding one per request throws that state away. There is no `close()` — it owns a `fetch`, not a pool. 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from the **one operation that resolves to `undefined`** (`globalFilings.globalFilingsDownload`). `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `FinnhubApiError` family, told apart by `err.kind`. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `FINNHUB_API_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions @@ -74,7 +150,7 @@ A standalone Finnhub API question with no code change: look it up in `typescript - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. - **Never write any Finnhub API or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. - **Don't web-search Finnhub API topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Finnhub API docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. diff --git a/plugins/finnhub/skills/typescript/typescript-models/SKILL.md b/plugins/finnhub/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/finnhub/skills/typescript/typescript-models/SKILL.md +++ b/plugins/finnhub/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/finnhub/skills/typescript/typescript-models/reference.md b/plugins/finnhub/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/finnhub/skills/typescript/typescript-models/reference.md +++ b/plugins/finnhub/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/finnhub/skills/typescript/typescript-testing/SKILL.md b/plugins/finnhub/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/finnhub/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/finnhub/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/firecrawl/.claude-plugin/plugin.json b/plugins/firecrawl/.claude-plugin/plugin.json index dececf977..e45a25213 100644 --- a/plugins/firecrawl/.claude-plugin/plugin.json +++ b/plugins/firecrawl/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "firecrawl", "displayName": "Firecrawl API", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Firecrawl API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/firecrawl/.cursor-plugin/plugin.json b/plugins/firecrawl/.cursor-plugin/plugin.json index dececf977..e45a25213 100644 --- a/plugins/firecrawl/.cursor-plugin/plugin.json +++ b/plugins/firecrawl/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "firecrawl", "displayName": "Firecrawl API", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Firecrawl API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/firecrawl/plugin.json b/plugins/firecrawl/plugin.json index dececf977..e45a25213 100644 --- a/plugins/firecrawl/plugin.json +++ b/plugins/firecrawl/plugin.json @@ -1,7 +1,7 @@ { "name": "firecrawl", "displayName": "Firecrawl API", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Firecrawl API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/firecrawl/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/firecrawl/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..21110f597 100644 --- a/plugins/firecrawl/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/firecrawl/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,245 +1,67 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Firecrawl API .NET SDK in C# — this API authenticates with a Bearer token, and this skill gives the credential each scheme takes, the FirecrawlApiClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Firecrawl API .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with a Bearer token. APIMatic surfaces each scheme as a **nullable credentials property** on `FirecrawlApiClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) - -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). - -## Basic auth - -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` +| Scheme | Property on `FirecrawlApiClientOptions` | Credential | +| --- | --- | --- | +| Bearer token | `BearerAuth` | `string` | -Sends `Authorization: Basic base64(username:password)`. +**That table is the entire authentication surface of this SDK.** `FirecrawlApi.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. ## Bearer token -Set the configured token property on the options class to your access-token string: +`options.BearerAuth` is a plain `string` — assign the access token itself, with no `Bearer` prefix, which the SDK adds: ```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; +options.BearerAuth = "ACCESS_TOKEN"; ``` Sends `Authorization: Bearer ACCESS_TOKEN`. -## API key (header, query, or cookie) - -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: - -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` - -## OAuth 2.0 — client credentials (machine-to-machine) - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; - -options.{OAuthProperty} = new OAuth2ClientCredentials -{ - ClientId = "...", - ClientSecret = "...", - Scope = "..." // optional -}; -``` - -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. - -## OAuth 2.0 — authorization code (3-legged, with PKCE) - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; - -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials -{ - ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE - RedirectUri = "https://app.example.com/callback", - Scope = "...", // optional - State = "...", // optional CSRF token - Pkce = PkceMethod.S256, // default; RFC 7636 - PromptForAuthorizationCode = async (authorizationUrl, ct) => - { - // Open/redirect the browser to authorizationUrl, then return the - // authorization code your redirect endpoint received. - return await GetCodeFromUserAsync(authorizationUrl, ct); - } -}; -``` - -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. - -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: - -| PKCE | token request built | no secret ⇒ | -| --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | - -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. - -## OAuth 2.0 — resource owner password - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; - -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` - -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. - -### Supplying your own token source - -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. - -```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) -``` - -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: - -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). - -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. - -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. - ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `FirecrawlApiClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("FirecrawlApiClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.BearerAuth)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "FirecrawlApiClient:BearerAuth is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"FirecrawlApiClient:BearerAuth is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +Check every value the schemes actually need. For this SDK that is: + +| Value to validate | Feeds `FirecrawlApiClientOptions` property | +| --- | --- | +| `BearerAuth` | `BearerAuth` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddFirecrawlApiClient(options => ...)` callback when registering via DI. `FirecrawlApiClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddFirecrawlApiClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/firecrawl/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/firecrawl/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/firecrawl/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/firecrawl/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/firecrawl/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/firecrawl/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/firecrawl/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/firecrawl/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/firecrawl/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/firecrawl/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/firecrawl/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/firecrawl/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/firecrawl/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/firecrawl/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/firecrawl/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/firecrawl/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/firecrawl/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/firecrawl/skills/dotnet/dotnet-getting-started/SKILL.md index 860475c75..fabdbecc4 100644 --- a/plugins/firecrawl/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/firecrawl/skills/dotnet/dotnet-getting-started/SKILL.md @@ -1,17 +1,17 @@ --- name: "dotnet-getting-started" -description: "Firecrawl API .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-firecrawl is the workflow that uses it." +description: "Firecrawl API .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-firecrawl-api is the workflow that uses it." --- # Getting started with the Firecrawl API .NET SDK -> **Who this skill is for.** This is the **map layer**: load it to ground every Firecrawl API SDK fact. `dotnet-integrate-firecrawl` is the workflow that drives it — you collect the contracts for the work in scope into `firecrawl-api-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. +> **Who this skill is for.** This is the **map layer**: load it to ground every Firecrawl API SDK fact. `dotnet-integrate-firecrawl-api` is the workflow that drives it — you collect the contracts for the work in scope into `firecrawl-api-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated .NET SDK (auth, calling endpoints, models, error handling, retries, testing), see the companion API-agnostic skills: `dotnet-client-initialization`, `dotnet-authentication`, `dotnet-calling-endpoints`, `dotnet-models`, `dotnet-error-handling`, `dotnet-configuration-resilience`, `dotnet-testing`. **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity @@ -26,9 +26,9 @@ This is the **SDK-specific** entry point. For general patterns that apply to any | Auth | **Bearer** token — set `BearerAuth` | | Environments | `ServerEnvironment.Production` *(default)* → `https://api.firecrawl.dev/v2` | | Log environment variable | `FIRECRAWLAPICLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | -The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-firecrawl` makes that binding); the **Integration workflow** below says which governs which step. +The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-firecrawl-api` makes that binding); the **Integration workflow** below says which governs which step. ## Namespaces (using-directives) @@ -44,22 +44,24 @@ Models, enums, union types and the generated error types each sit in their own c ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Firecrawl API, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/FirecrawlApi/Models/`, `src/FirecrawlApi/Errors/` and `src/FirecrawlApi/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/FirecrawlApi/Models/`, enums `src/FirecrawlApi/Models/Enums/`, typed errors `src/FirecrawlApi/Errors/`, request records `src/FirecrawlApi/Requests/`, or `src/FirecrawlApi/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: @@ -69,7 +71,7 @@ Keep lookups cheap — the rules that keep a session's context small: ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/firecrawl-api-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/firecrawl-api-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. **Clone once, up front**, into a fresh timestamped folder under `/firecrawl-api-sdk-src/`, and reuse that folder for the rest of your session: @@ -87,30 +89,31 @@ git clone --filter=blob:none --branch main https://github.com/context-plugins/fi # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/firecrawl-api-sdk-src/`, **never** in the project repo, and its path never goes into `firecrawl-api-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `firecrawl-api-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `FirecrawlApiClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/FirecrawlApi/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/FirecrawlApi/Models/` (+ `src/FirecrawlApi/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/FirecrawlApi/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/FirecrawlApi/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/FirecrawlApi/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/FirecrawlApi/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/FirecrawlApi/Servers/`, `src/FirecrawlApi/FirecrawlApiClient.cs`, `src/FirecrawlApi/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Firecrawl API may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything @@ -126,17 +129,17 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/FirecrawlApi/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step -You loaded every companion the sheet named before starting (per `dotnet-integrate-firecrawl`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: +You loaded every companion the sheet named before starting (per `dotnet-integrate-firecrawl-api`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: 1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new FirecrawlApiClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) 2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `FirecrawlApiClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/firecrawl/skills/dotnet/dotnet-integrate-firecrawl/SKILL.md b/plugins/firecrawl/skills/dotnet/dotnet-integrate-firecrawl-api/SKILL.md similarity index 65% rename from plugins/firecrawl/skills/dotnet/dotnet-integrate-firecrawl/SKILL.md rename to plugins/firecrawl/skills/dotnet/dotnet-integrate-firecrawl-api/SKILL.md index 145fc26a7..d43417b04 100644 --- a/plugins/firecrawl/skills/dotnet/dotnet-integrate-firecrawl/SKILL.md +++ b/plugins/firecrawl/skills/dotnet/dotnet-integrate-firecrawl-api/SKILL.md @@ -1,125 +1,173 @@ ---- -name: "dotnet-integrate-firecrawl" -description: "MANDATORY FIRST STEP for Firecrawl API .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Firecrawl API in C# — Account, Agent, Billing, Crawling, Developer, Extraction, Feedback, Interact, Mapping, Miscellaneous, Monitoring, ResearchApi, Scraping, Search, Support, ThreatProtection — or when a Firecrawl API .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file firecrawl-api-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." ---- - -# Firecrawl API .NET SDK — Workflow (map + skills) - -You are the Firecrawl API .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. - -**Scope guard:** the APIMatic-generated Firecrawl API **.NET SDK** (root namespace `FirecrawlApi`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. - -## Grounding rules - -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `firecrawl-api-plan.md` — the plan must stay portable. -- **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. -- **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. -- **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. -- **The sheet records Firecrawl API's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. -- **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. - -## Workflow - -**If the user opens with a reported SDK error or unexpected Firecrawl API behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: - -### Step 1 — Plan first: write `firecrawl-api-plan.md` (always, for any implementation work) - -Your FIRST deliverable is `firecrawl-api-plan.md` at `/firecrawl-api-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. - -#### How to ground (map-first, one pass) - -1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. -3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. -4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. -5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. - -#### Prerequisites you can run alongside - -These need no SDK knowledge and touch no project file, so do them before or alongside the map work: - -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); -- the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; -- locating the SDK reference and its version in the project; -- credentials/environment verification (per the task's secret-handling rules); -- setting up your task tracking. - -Never use this prerequisite work as a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. - -#### HARD GATE - -**No project-file creation or edits until `firecrawl-api-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. - -#### The `firecrawl-api-plan.md` format (keep it tight — tables, not prose) - -1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. -3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. -6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. -7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. - -| # | Concern | The decision the plan must record | -| --- | --- | --- | -| 1 | **Credential fail-fast** | Where credentials are bound, and that the host refuses to start when one is missing or blank — rather than discovering it as a 401 on the first call in production. A multi-part credential needs **every** part checked, because a blank part is not a missing one. | -| 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | -| 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | -| 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | -| 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | -| 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | -| 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | - -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. - -Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. - -### Step 2 — Required reading (do this before you write any code) - -The contract sheet ends with a **REQUIRED READING** block, and its rows carry inline `MUST load ` pointers. **Load every `dotnet-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. If the sheet names none, load `dotnet-error-handling` anyway — every integration writes an error boundary. - -These are API-agnostic usage skills; loading them is not the same as reading the map. Contract *facts* still come only from the sheet or a map lookup. - -Before implementing, check the plan's **Assumptions & Blockers** section: - -- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `firecrawl-api-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. -- Minor assumptions only → proceed. - -Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. - -### Step 3 — Implement from the contract sheet - -1. Read `firecrawl-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. -2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. -3. After every change: `dotnet build`; fix non-SDK errors yourself. -4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `FirecrawlApi.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. - -### Step 4 — Fixing SDK errors (map-first, in place) - -A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `FirecrawlApi.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. - -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. -3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". -4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). -5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. -6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. - -If a fix corrects a row in `firecrawl-api-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. - -### Step 5 — Pure questions - -A standalone Firecrawl API question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. - -## Anti-patterns — never do these - -- **Never write a Firecrawl API/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** -- **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. -- **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. -- **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search Firecrawl API for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `FirecrawlApi` is what the generated source declares, not what public docs describe. -- **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. - +--- +name: "dotnet-integrate-firecrawl-api" +description: "MANDATORY FIRST STEP for Firecrawl API .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Firecrawl API in C# — Account, Agent, Billing, Crawling, Developer, Extraction, Feedback, Interact, Mapping, Miscellaneous, Monitoring, ResearchApi, Scraping, Search, Support, ThreatProtection — or when a Firecrawl API .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file firecrawl-api-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." +--- + +# Firecrawl API .NET SDK — Workflow (map + skills) + +You are the Firecrawl API .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. + +**Scope guard:** the APIMatic-generated Firecrawl API **.NET SDK** (root namespace `FirecrawlApi`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. + +## Grounding rules + +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `firecrawl-api-plan.md` never carries its path — the plan must stay portable. +- **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. +- **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. +- **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. +- **The sheet records Firecrawl API's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. +- **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. + +## Workflow + +**If the user opens with a reported SDK error or unexpected Firecrawl API behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: + +### Step 1 — Plan first: write `firecrawl-api-plan.md` (always, for any implementation work) + +Your FIRST deliverable is `firecrawl-api-plan.md` at `/firecrawl-api-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. + +#### How to ground (map-first, one pass) + +1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. +3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. +4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. +5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. + +#### Prerequisites you can run alongside + +These need no SDK knowledge and touch no project file, so do them before or alongside the map work: + +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; +- `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; +- locating the SDK reference and its version in the project; +- credentials/environment verification (per the task's secret-handling rules); +- setting up your task tracking. + +Never use this prerequisite work as a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. + +#### HARD GATE + +**No project-file creation or edits until `firecrawl-api-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. + +#### The `firecrawl-api-plan.md` format (keep it tight — tables, not prose) + +1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + +3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. +7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. + +| # | Concern | The decision the plan must record | +| --- | --- | --- | +| 1 | **Credential fail-fast** | Where credentials are bound, and that the host refuses to start when one is missing or blank — rather than discovering it as a 401 on the first call in production. A multi-part credential needs **every** part checked, because a blank part is not a missing one. | +| 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | +| 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | +| 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | +| 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | +| 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. + +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. + +Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. + +### Step 2 — Required reading (do this before you write any code) + +The contract sheet ends with a **REQUIRED READING** block, and its rows carry inline `MUST load ` pointers. **Load every `dotnet-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. If the sheet names none, load `dotnet-error-handling` anyway — every integration writes an error boundary. + +These are API-agnostic usage skills; loading them is not the same as reading the map. Contract *facts* still come only from the sheet or a map lookup. + +Before implementing, check the plan's **Assumptions & Blockers** section: + +- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `firecrawl-api-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. +- Minor assumptions only → proceed. + +Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. + +### Step 3 — Implement from the contract sheet + +1. Read `firecrawl-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. +2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. +3. After every change: `dotnet build`; fix non-SDK errors yourself. +4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `FirecrawlApi.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. + +### Step 4 — Fixing SDK errors (map-first, in place) + +A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `FirecrawlApi.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. + +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/FirecrawlApi/Models/{Type}.cs`, `src/FirecrawlApi/Models/Enums/{Type}.cs`, `src/FirecrawlApi/Errors/{Type}.cs`, `src/FirecrawlApi/Requests/{Type}.cs`, or `src/FirecrawlApi/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. +3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". +4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). +5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. +6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. + +If a fix corrects a row in `firecrawl-api-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. + +### Step 5 — Pure questions + +A standalone Firecrawl API question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. + +## Anti-patterns — never do these + +- **Never write a Firecrawl API/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** +- **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. +- **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. +- **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. +- **Do not web-search Firecrawl API for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `FirecrawlApi` is what the generated source declares, not what public docs describe. +- **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. + diff --git a/plugins/firecrawl/skills/dotnet/dotnet-models/SKILL.md b/plugins/firecrawl/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/firecrawl/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/firecrawl/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/firecrawl/skills/dotnet/dotnet-models/reference.md b/plugins/firecrawl/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/firecrawl/skills/dotnet/dotnet-models/reference.md +++ b/plugins/firecrawl/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/firecrawl/skills/dotnet/dotnet-testing/SKILL.md b/plugins/firecrawl/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/firecrawl/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/firecrawl/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/firecrawl/skills/python/python-authentication/SKILL.md b/plugins/firecrawl/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/firecrawl/skills/python/python-authentication/SKILL.md +++ b/plugins/firecrawl/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/firecrawl/skills/python/python-calling-endpoints/SKILL.md b/plugins/firecrawl/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/firecrawl/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/firecrawl/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/firecrawl/skills/python/python-client-initialization/SKILL.md b/plugins/firecrawl/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/firecrawl/skills/python/python-client-initialization/SKILL.md +++ b/plugins/firecrawl/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/firecrawl/skills/python/python-configuration-resilience/SKILL.md b/plugins/firecrawl/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/firecrawl/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/firecrawl/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/firecrawl/skills/python/python-error-handling/SKILL.md b/plugins/firecrawl/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/firecrawl/skills/python/python-error-handling/SKILL.md +++ b/plugins/firecrawl/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/firecrawl/skills/python/python-event-streams/SKILL.md b/plugins/firecrawl/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/firecrawl/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/firecrawl/skills/python/python-file-handling/SKILL.md b/plugins/firecrawl/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/firecrawl/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/firecrawl/skills/python/python-getting-started/SKILL.md b/plugins/firecrawl/skills/python/python-getting-started/SKILL.md index 657577bc5..0e7c9a10c 100644 --- a/plugins/firecrawl/skills/python/python-getting-started/SKILL.md +++ b/plugins/firecrawl/skills/python/python-getting-started/SKILL.md @@ -5,7 +5,7 @@ description: "Firecrawl API Python SDK identity and lookup layer (Python only) # Getting started with the Firecrawl API Python SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Firecrawl API Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-firecrawl` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Firecrawl API Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-firecrawl-api` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated Python SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `python-client-initialization`, `python-authentication`, `python-calling-endpoints`, `python-models`, `python-error-handling`, `python-configuration-resilience` and `python-testing`. @@ -25,27 +25,32 @@ Verified against `firecrawl_api/` and `pyproject.toml` of the generated package | Version | `v2` | | Sync client class | `FirecrawlApiClient` (alias `Client`) | | Async client class | `AsyncFirecrawlApiClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`) · `bearer_auth`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`) · `retry_options` · `bearer_auth`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | **Bearer** token — set `bearer_auth` | | Environments | **no environment enum** — one `base_url` string, defaulting to `https://api.firecrawl.dev/v2` | | Base-URL config | `ServerConfig` (`firecrawl_api/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | -| Surface | 52 operations across 16 controllers · 303 models · 16 unions · 51 enums · 44 per-operation error unions | +| Surface | 52 operations across 16 controllers · 303 models · 16 unions · 85 enums · 44 per-operation error unions | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, import roots, the auth *pattern*, the base-URL knob), while the actual integration code comes from the companion skills. Load each one as you reach its step (see **Integration workflow** below) and confirm its types against the installed package. ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash pip install "firecrawl-api @ git+https://github.com/context-plugins/firecrawl-python-sdk.git@main" +python -c "import firecrawl_api, pathlib; print(pathlib.Path(firecrawl_api.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules @@ -69,7 +74,7 @@ Everything else comes from its own subpackage, and the split matters because eac | --- | --- | | Domain models, their `…Dict` companions | `firecrawl_api.models` | | Enums (and their open `…OrStr` aliases) | `firecrawl_api.models.enums` | -| `ApiError` · `RawError` · `ApiResult` · `RequestOptions` · `HttpClient` · `SdkBaseModel` · `UNSET` · `Optional` | `firecrawl_api.core` | +| `ApiError` · `RawError` · `ApiResult` · `RequestOptions` · `HttpClient` · `SdkBaseModel` · `UNSET` · `Optional` · `FileInput` · `AsyncFileInput` · `NamedFile` · `BinaryContent` · `AsyncBinaryContent` · `BinaryReader` · `AsyncBinaryReader` | `firecrawl_api.core` | | Per-operation error *unions* | `firecrawl_api.errors` (`AskSupportAgentErrorBody`, …) | `firecrawl_api.core` re-exports its whole public surface (a curated `__all__`), so import from `…core` rather than from the private modules beneath it (`…core.results`, `…core.exceptions`, `…core.auth.schemes`). @@ -134,15 +139,15 @@ The SDK map's controller table carries the same counts and links to a page per c The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `firecrawl_api/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `firecrawl_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `firecrawl_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/firecrawl-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. Every `Source` path on the map is relative to that SDK root, so `firecrawl_api/models/actions.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. @@ -162,7 +167,7 @@ Read the one module that owns the fact **inside the installed package**. Locate python -c "import firecrawl_api, pathlib; print(pathlib.Path(firecrawl_api.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/firecrawl_api` on Windows, `.venv/lib/python3.*/site-packages/firecrawl_api` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/firecrawl_api` on Windows, `.venv/lib/python3.*/site-packages/firecrawl_api` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -170,7 +175,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/fi | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -179,8 +184,9 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/fi | An enum's members and wire values | `models/enums/` | | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | +| What a file or binary parameter accepts, and the reader protocols behind it (the aliases are in the import table above; not in the map's Type sources) | `core/files.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | | An operation's error mapper (status → schema) | `errors/_error.py` | @@ -197,13 +203,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The one scheme is `bearer_auth=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; Load secrets from the environment or a secret store, never hardcode.) -3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; and the two response modes — raising vs `ApiResult` — behave differently on failure.) +3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; a multipart body is **one flat parameter list** — every field, JSON part and file is its own parameter, and an optional part is omitted with `None` rather than by a second call shape; and the two response modes — raising vs `ApiResult` — behave differently on failure.) (*For the file positions:* load **python-file-handling** before you pass anything but a `Path` — a file position accepts far more than `bytes`, **never a `str`**, and nothing validates what you hand it.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`), so an unknown wire value passes through as a plain `str` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -211,9 +217,10 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 1. **Sync or async** — which client class, and the reminder that the two do not mix. Plus the `close()`/`aclose()` obligation and where the client is held. 2. **The keyword-only boundary** for each operation: what is positional (path params, sometimes the body) and what sits after `*`. Every keyword-only parameter has a real default, so there is no "must pass `None` explicitly" hazard — say so, so nobody writes defensive `None`s. -3. **Every operation returns a payload** — no operation in this SDK returns `None`, so the `with_raw_response` peer is needed only where the status code itself matters. -4. **Required vs `UNSET`** for every model member the task sets, and the fact that `Optional[T]` here is `T | UnsetType` — **not** `typing.Optional`, so `None` is not a legal value for it. -5. **The `ApiError.error` union** for each operation in scope — there are **57** typed error bodies in this SDK, so the union is never uniform. Every union is ` | RawError`: +3. **The file positions** — `scraping.parse_file`, each annotated `FileInput` on the sync client and `AsyncFileInput` on the async one, with `list[…]` for an array part. What those admit is wider than the annotation shows and **never a `str`**, and the arm you pick decides what stays resident, who closes your handle and what the file is called on the wire — so the sheet cites **python-file-handling** for the row rather than restating it. +4. **Every operation returns a payload** — no operation in this SDK returns `None`, so the `with_raw_response` peer is needed only where the status code itself matters. +5. **Required vs `UNSET`** for every model member the task sets, and the fact that `Optional[T]` here is `T | UnsetType` — **not** `typing.Optional`, so `None` is not a legal value for it. +6. **The `ApiError.error` union** for each operation in scope — there are **57** typed error bodies in this SDK, so the union is never uniform. Every union is ` | RawError`: 1. `BatchScrape500Error1` — 3 operations (`scraping.cancel_batch_scrape` · `scraping.get_batch_scrape_status` · `scraping.scrape_and_extract_from_urls`); distinguishing members *none required* 2. `Crawl500Error1` — 3 operations (`crawling.cancel_crawl` · `crawling.crawl_urls` · `crawling.get_crawl_status`); distinguishing members *none required* 3. `FeedbackErrorResponse` — 3 operations (`feedback.submit_endpoint_feedback` · `feedback.submit_search_feedback` · `search.submit_search_feedback`); distinguishing members `success` · `error` @@ -273,8 +280,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 57. `TeamTokenUsageHistorical500Error1` — 1 operation (`billing.get_historical_token_usage`); distinguishing members *none required* 58. *(none)* — `account.get_activity` · `agent.cancel_agent` · `agent.get_agent_status` · `developer.developer_search` · `developer.developer_search_post` · `extraction.get_extract_status` · `miscellaneous.get_queue_status` · `monitoring.create_monitor` · `monitoring.delete_monitor` · `monitoring.get_monitor` · `monitoring.get_monitor_check` · `monitoring.list_monitor_checks` · `monitoring.list_monitors` · `monitoring.run_monitor` · `monitoring.update_monitor` · `research_api.research_get_paper` · `research_api.research_related_papers` · `research_api.research_search_papers` · `threat_protection.get_threat_protection` · `threat_protection.update_threat_protection`: no typed arm, so `.error` is always `RawError` 59. So `isinstance(e.error, BatchScrape500Error1)` matches only 3 of 52 operations. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 6 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which host the `base_url` selects**, because omitting it is silently the default. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +7. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 6 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +8. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +9. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +10. **Which host the `base_url` selects**, because omitting it is silently the default. +11. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/firecrawl/skills/python/python-integrate-firecrawl/SKILL.md b/plugins/firecrawl/skills/python/python-integrate-firecrawl-api/SKILL.md similarity index 81% rename from plugins/firecrawl/skills/python/python-integrate-firecrawl/SKILL.md rename to plugins/firecrawl/skills/python/python-integrate-firecrawl-api/SKILL.md index aa494295c..abfc26392 100644 --- a/plugins/firecrawl/skills/python/python-integrate-firecrawl/SKILL.md +++ b/plugins/firecrawl/skills/python/python-integrate-firecrawl-api/SKILL.md @@ -1,81 +1,97 @@ ---- -name: "python-integrate-firecrawl" -description: "MANDATORY FIRST STEP for Firecrawl API Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Firecrawl API in Python — or when a Firecrawl API Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." ---- - -# Firecrawl API Python SDK — Integration workflow (lookup layer + contract sheet) - -You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. - -## Your lookup layer - -- **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. -- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import firecrawl_api, pathlib; print(pathlib.Path(firecrawl_api.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) -- **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. -- **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. - -**Scope guard:** the APIMatic-generated Firecrawl API **Python SDK** (import root `firecrawl_api`, distribution `firecrawl-api`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. - -## Workflow - -**If the user opens with a reported SDK error or unexpected Firecrawl API behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: - -### Step 1 — Plan first (always, for any implementation work) - -Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/firecrawl-api-plan.md` — that absolute path, not a location you pick later. - -**Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - -- the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `firecrawl-api` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; -- a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — including which host `base_url` will select, since omitting it is silent; -- **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; -- setting up your task tracking. - -Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. - -**HARD GATE — no project-file creation or edits until:** `firecrawl-api-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. - -**What the gate does not cover:** creating or activating a virtual environment, installing `firecrawl-api` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. - -### Step 1c — Required reading (do this before you write any code) - -End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. - -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `python-error-handling` always (every integration writes an error boundary); `python-client-initialization` before the client is constructed; `python-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes the transport. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. - -These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a module in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. - -Before implementing, check the plan's **Assumptions & Blockers** section: - -- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `firecrawl-api-plan.md` in place. -- Minor assumptions only → proceed. - -Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. - -### Step 2 — Implement from the contract sheet - -1. Read `firecrawl-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). -3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. -4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. -5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `firecrawl_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. - -### Step 3 — Answering pure questions - -A standalone Firecrawl API question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. - -## Anti-patterns — never do these - -- **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. -- **Never write a Firecrawl API/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Don't web-search Firecrawl API topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Firecrawl API docs describe the REST API, not this SDK's generated surface, and the two disagree on names. -- **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. -- **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. -- **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. -- **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. - +--- +name: "python-integrate-firecrawl-api" +description: "MANDATORY FIRST STEP for Firecrawl API Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Firecrawl API in Python — or when a Firecrawl API Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." +--- + +# Firecrawl API Python SDK — Integration workflow (lookup layer + contract sheet) + +You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. + +## Your lookup layer + +- **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. +- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import firecrawl_api, pathlib; print(pathlib.Path(firecrawl_api.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. +- **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. + +**Scope guard:** the APIMatic-generated Firecrawl API **Python SDK** (import root `firecrawl_api`, distribution `firecrawl-api`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. + +## Workflow + +**If the user opens with a reported SDK error or unexpected Firecrawl API behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: + +### Step 1 — Plan first (always, for any implementation work) + +Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/firecrawl-api-plan.md` — that absolute path, not a location you pick later. + +**Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** + +- the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; +- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `firecrawl-api` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; +- a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; +- credentials/environment verification (per the task's secret-handling rules) — including which host `base_url` will select, since omitting it is silent; +- **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; +- setting up your task tracking. + +Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. + +**HARD GATE — no project-file creation or edits until:** `firecrawl-api-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. + +**What the gate does not cover:** creating or activating a virtual environment, installing `firecrawl-api` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +### Step 1c — Required reading (do this before you write any code) + +End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. + +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `python-error-handling` always (every integration writes an error boundary); `python-client-initialization` before the client is constructed; `python-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes the transport. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. + +These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a module in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. + +Before implementing, check the plan's **Assumptions & Blockers** section: + +- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `firecrawl-api-plan.md` in place. +- Minor assumptions only → proceed. + +Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. + +### Step 2 — Implement from the contract sheet + +1. Read `firecrawl-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. +2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). +3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. +4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. +5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `firecrawl_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. + +### Step 3 — Answering pure questions + +A standalone Firecrawl API question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. + +## Anti-patterns — never do these + +- **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. +- **Never write a Firecrawl API/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** +- **Don't web-search Firecrawl API topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Firecrawl API docs describe the REST API, not this SDK's generated surface, and the two disagree on names. +- **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. +- **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. +- **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. +- **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. + diff --git a/plugins/firecrawl/skills/python/python-models/SKILL.md b/plugins/firecrawl/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/firecrawl/skills/python/python-models/SKILL.md +++ b/plugins/firecrawl/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/firecrawl/skills/python/python-models/reference.md b/plugins/firecrawl/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/firecrawl/skills/python/python-models/reference.md +++ b/plugins/firecrawl/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/firecrawl/skills/python/python-testing/SKILL.md b/plugins/firecrawl/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/firecrawl/skills/python/python-testing/SKILL.md +++ b/plugins/firecrawl/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/firecrawl/skills/typescript/typescript-authentication/SKILL.md b/plugins/firecrawl/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/firecrawl/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/firecrawl/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/firecrawl/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/firecrawl/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/firecrawl/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/firecrawl/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/firecrawl/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/firecrawl/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/firecrawl/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/firecrawl/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/firecrawl/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/firecrawl/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/firecrawl/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/firecrawl/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/firecrawl/skills/typescript/typescript-error-handling/SKILL.md b/plugins/firecrawl/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/firecrawl/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/firecrawl/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/firecrawl/skills/typescript/typescript-getting-started/SKILL.md b/plugins/firecrawl/skills/typescript/typescript-getting-started/SKILL.md index 43a37b1eb..17cb03f6b 100644 --- a/plugins/firecrawl/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/firecrawl/skills/typescript/typescript-getting-started/SKILL.md @@ -5,7 +5,7 @@ description: "Firecrawl API TypeScript SDK identity and lookup layer (TypeScript # Getting started with the Firecrawl API TypeScript SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Firecrawl API TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-firecrawl` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Firecrawl API TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-firecrawl-api` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated TypeScript SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `typescript-client-initialization`, `typescript-authentication`, `typescript-calling-endpoints`, `typescript-models`, `typescript-error-handling`, `typescript-configuration-resilience` and `typescript-testing`. @@ -13,46 +13,48 @@ This is the **SDK-specific** entry point. For general patterns that apply to any ## SDK identity -Verified against `package.json` and `sdk-map.md` of the generated package at version `v2`. **Re-verify after a version bump** — this page is a snapshot, not a live read. +Verified against `package.json` and `sdk-map.md` of the generated package at version `2.0.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. | Fact | Value | | --- | --- | | API | Firecrawl API | | Package name (what you install, and what you import) | `firecrawl-api` — **not on npm**; built from source (see *Install*) | | Import specifier | `firecrawl-api` — the package root is the **only** entry; deep imports do not resolve | -| Version | `v2` (API spec version `v2`) | +| Source repository | https://github.com/context-plugins/firecrawl-typescript-sdk (branch `main` — the ref this map documents) | +| Version | `2.0.0` (API spec version `v2`) | | Client class | `FirecrawlApiClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new FirecrawlApiClient(clientOptions: Partial = {})` — **every** field is optional, so `new FirecrawlApiClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `bearerAuth`. `timeout` defaults to `60_000` ms | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new FirecrawlApiClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new FirecrawlApiClient()` compiles. Fields: `serverOptions` · `retry` · `fetch` · `bearerAuth`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **Bearer token** — set `ClientOptions.bearerAuth` | -| Environments | 1 environment (`ServerEnvironment.Production` *(default)*) × 1 server group | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`), defaulting to `https://api.firecrawl.dev/v2` | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Environments | no selector — one environment, so none is emitted; 1 server group | +| Base-URL config | `serverOptions.baseUrl` (`src/servers.ts`), defaulting to `https://api.firecrawl.dev/v2` | +| Log environment variable | `FIRECRAWL_API_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | -| Surface | 52 operations · 16 resources · 303 models · 51 open enums · 16 unions · 45 per-operation error subclasses | +| Surface | 52 operations · 16 resources · 303 models · 85 open enums · 16 unions · 45 per-operation error subclasses | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, import specifier, the auth *pattern*, the base-URL knob), while the actual integration code comes from the companion skills. Load each one as you reach its step (see **Integration workflow** below) and confirm its types against the installed package. ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/firecrawl-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 370 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, 404 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { FirecrawlApiClient, ServerEnvironment, ResponseError, FirecrawlApiError } from "firecrawl-api"; +import { FirecrawlApiClient, ApiError, FirecrawlApiError } from "firecrawl-api"; import type { ClientOptions, Action } from "firecrawl-api"; ``` @@ -66,21 +68,19 @@ Things the specifier alone will not tell you: Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, every model type) must be imported with `import type`. Under `exactOptionalPropertyTypes`, **omit or spread** an absent optional field rather than assigning `undefined` to it. -## Environments +## Hosts -`ClientOptions.serverEnvironment` selects one environment for the whole client (`src/servers.ts`). `ServerEnvironment` is a `const` object with a derived union type — not a TypeScript `enum` — and unlike the model enums it is **closed**, so only its declared members are assignable. +This API declares one environment, so the SDK has **no environment selector** — there is no `ServerEnvironment` type and no field to set (`src/servers.ts`). The host below is the only one the SDK knows, and the override in the last column is the only way to reach another. -| Group | Environment | Base URL | Override at | -| --- | --- | --- | --- | -| `default` | `production` *(default)* | `https://api.firecrawl.dev/v2` | `serverOptions.default.production.baseUrl` | +| Group | Base URL | Override at | +| --- | --- | --- | +| `default` | `https://api.firecrawl.dev/v2` | `serverOptions.baseUrl` | Consequences to state on every contract sheet that touches configuration: -- Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- ⚠ **This SDK declares exactly one environment**, so reaching any other host is a `serverOptions` override, not an environment value — `serverOptions: { default: { production: { baseUrl: "…" } } }`. Note the key is still `production`: the environment name and the host it points at are now decoupled, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the deployment's intent explicit where the client is built, and verify it. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- ⚠ **This SDK declares exactly one environment**, so it emits no `ServerEnvironment` at all and reaching any other host is a `serverOptions` base-URL override — `serverOptions: { baseUrl: "…" }`. Nothing in the options then records **which** deployment this is, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the intent explicit where the client is built, and verify it. +- An override merges with the built-in default **key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. ## Auth pattern (1 scheme) @@ -102,7 +102,7 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. See `typescript-authentication` for the full picture. @@ -129,7 +129,7 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | `client.developer` | `Developer` | 2 | `developerSearch` · `developerSearchPost` | | `client.threatProtection` | `ThreatProtection` | 2 | `getThreatProtection` · `updateThreatProtection` | -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** One operation takes `RequestParams` instead: `crawlParamsPreview`. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -137,7 +137,7 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: @@ -146,11 +146,11 @@ Locate the installed package before you rely on a lookup: node -e "console.log(require.resolve('firecrawl-api/package.json'))" ``` -Failing that it is at `node_modules/firecrawl-api/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/firecrawl-api/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/firecrawl-api/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -158,7 +158,7 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/firecrawl-api/`: @@ -168,13 +168,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -188,30 +190,38 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `FIRECRAWL_API_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new FirecrawlApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new FirecrawlApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) 2. **Authentication** — load **typescript-authentication** before you set credentials. The scheme is `bearerAuth` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **no operation resolves to `undefined` — every one of them carries a body**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `FirecrawlApiError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `FirecrawlApiError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `FIRECRAWL_API_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK Beyond the usual signatures and model members, a contract sheet for the Firecrawl API TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. -1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment, `ServerEnvironment.Production`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. +1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment and emits no `ServerEnvironment`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. 2. **no operation resolves to `undefined` — every one of them carries a body** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet — One operation takes `RequestParams`, not `Request`. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 45 of 52 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. **That `Error`, `Event` imported from this package are model types, not the globals of those names** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `FirecrawlApiError` for the same reason. -10. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 45 of 52 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. **That `Error`, `Event` imported from this package are model types, not the globals of those names** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `FirecrawlApiError` for the same reason. +12. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/firecrawl/skills/typescript/typescript-integrate-firecrawl/SKILL.md b/plugins/firecrawl/skills/typescript/typescript-integrate-firecrawl-api/SKILL.md similarity index 51% rename from plugins/firecrawl/skills/typescript/typescript-integrate-firecrawl/SKILL.md rename to plugins/firecrawl/skills/typescript/typescript-integrate-firecrawl-api/SKILL.md index 0aed19749..254f035b5 100644 --- a/plugins/firecrawl/skills/typescript/typescript-integrate-firecrawl/SKILL.md +++ b/plugins/firecrawl/skills/typescript/typescript-integrate-firecrawl-api/SKILL.md @@ -1,6 +1,6 @@ --- -name: "typescript-integrate-firecrawl" -description: "MANDATORY FIRST STEP for Firecrawl API TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Firecrawl API in TypeScript/Node — create monitor, delete monitor, get monitor, get monitor check, list monitor checks, list monitors, or when the Firecrawl API TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `firecrawl-api` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (firecrawl-api-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares only ServerEnvironment.Production, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." +name: "typescript-integrate-firecrawl-api" +description: "MANDATORY FIRST STEP for Firecrawl API TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Firecrawl API in TypeScript/Node — create monitor, delete monitor, get monitor, get monitor check, list monitor checks, list monitors, or when the Firecrawl API TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `firecrawl-api` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (firecrawl-api-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares one environment and no ServerEnvironment, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." --- # Firecrawl API TypeScript SDK — Integration workflow (lookup layer + contract sheet) @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/firecrawl-api/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('firecrawl-api/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('firecrawl-api/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. @@ -23,14 +23,14 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/firecrawl-api-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/firecrawl-api-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, **module format — ESM or CJS, which changes how the SDK is imported and whether `instanceof` on its error classes is trustworthy**, and the runtime: Node, a bundled browser app, or an edge runtime) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - **establish the toolchain before you need it**: the package manager, whether `firecrawl-api` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; - a baseline run of the project's checks (`tsc --noEmit`, the test runner, the linter — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment (`ServerEnvironment.Production`), so **any other host is a `serverOptions` base-URL override rather than an environment value**; +- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment, so it emits no `ServerEnvironment` at all and **any other host is a `serverOptions` base-URL override rather than an environment value**; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your credential may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; - setting up your task tracking. @@ -40,11 +40,85 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** installing `firecrawl-api` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. @@ -58,13 +132,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp ### Step 2 — Implement from the contract sheet 1. Read `firecrawl-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment — `ServerEnvironment.Production` — so a different host is a `serverOptions` override under a key still named after `production`: the environment name and the host it points at are decoupled, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. +2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment and emits no selector for it, so a different host is a `serverOptions` base-URL override. Nothing in the options then records **which** deployment this is, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. 3. **Build the client once and hold it.** Resources are memoized lazy getters on it, so rebuilding one per request throws that state away. There is no `close()` — it owns a `fetch`, not a pool. 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from an operation whose success carries no body. `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `FirecrawlApiError` family, told apart by `err.kind`. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `FIRECRAWL_API_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions @@ -74,7 +150,7 @@ A standalone Firecrawl API question with no code change: look it up in `typescri - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. - **Never write any Firecrawl API or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. - **Don't web-search Firecrawl API topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Firecrawl API docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. diff --git a/plugins/firecrawl/skills/typescript/typescript-models/SKILL.md b/plugins/firecrawl/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/firecrawl/skills/typescript/typescript-models/SKILL.md +++ b/plugins/firecrawl/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/firecrawl/skills/typescript/typescript-models/reference.md b/plugins/firecrawl/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/firecrawl/skills/typescript/typescript-models/reference.md +++ b/plugins/firecrawl/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/firecrawl/skills/typescript/typescript-testing/SKILL.md b/plugins/firecrawl/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/firecrawl/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/firecrawl/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/frankfurter/.claude-plugin/plugin.json b/plugins/frankfurter/.claude-plugin/plugin.json index 3c08ecec9..26e5fa828 100644 --- a/plugins/frankfurter/.claude-plugin/plugin.json +++ b/plugins/frankfurter/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "frankfurter", "displayName": "Frankfurter API", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Frankfurter API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/frankfurter/.cursor-plugin/plugin.json b/plugins/frankfurter/.cursor-plugin/plugin.json index 3c08ecec9..26e5fa828 100644 --- a/plugins/frankfurter/.cursor-plugin/plugin.json +++ b/plugins/frankfurter/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "frankfurter", "displayName": "Frankfurter API", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Frankfurter API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/frankfurter/plugin.json b/plugins/frankfurter/plugin.json index 3c08ecec9..26e5fa828 100644 --- a/plugins/frankfurter/plugin.json +++ b/plugins/frankfurter/plugin.json @@ -1,7 +1,7 @@ { "name": "frankfurter", "displayName": "Frankfurter API", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Frankfurter API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/frankfurter/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/frankfurter/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..843bcff46 100644 --- a/plugins/frankfurter/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/frankfurter/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,245 +1,19 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Frankfurter API .NET SDK in C# — this API declares no security schemes, so FrankfurterApiClientOptions exposes no credentials properties at all. Load to confirm that before hunting for an auth knob, or when a call unexpectedly comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Frankfurter API .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +**The Frankfurter API API declares no security schemes, so this SDK generates no credentials properties at all.** `FrankfurterApiClientOptions` has no authentication surface — there is nothing to set and no scheme to configure. Construct the client as `dotnet-client-initialization` describes and call operations directly. -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. +`FrankfurterApi.Core.Authentication` still ships the scheme classes as shared runtime code — every APIMatic .NET SDK carries that folder whether its API uses it or not. Its presence is **not** evidence of an auth knob you have missed. -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) +## Operations that need no credentials -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). - -## Basic auth - -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` - -Sends `Authorization: Basic base64(username:password)`. - -## Bearer token - -Set the configured token property on the options class to your access-token string: - -```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; -``` - -Sends `Authorization: Bearer ACCESS_TOKEN`. - -## API key (header, query, or cookie) - -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: - -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` - -## OAuth 2.0 — client credentials (machine-to-machine) - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; - -options.{OAuthProperty} = new OAuth2ClientCredentials -{ - ClientId = "...", - ClientSecret = "...", - Scope = "..." // optional -}; -``` - -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. - -## OAuth 2.0 — authorization code (3-legged, with PKCE) - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; - -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials -{ - ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE - RedirectUri = "https://app.example.com/callback", - Scope = "...", // optional - State = "...", // optional CSRF token - Pkce = PkceMethod.S256, // default; RFC 7636 - PromptForAuthorizationCode = async (authorizationUrl, ct) => - { - // Open/redirect the browser to authorizationUrl, then return the - // authorization code your redirect endpoint received. - return await GetCodeFromUserAsync(authorizationUrl, ct); - } -}; -``` - -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. - -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: - -| PKCE | token request built | no secret ⇒ | -| --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | - -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. - -## OAuth 2.0 — resource owner password - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; - -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` - -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. - -### Supplying your own token source - -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. - -```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) -``` - -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: - -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). - -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. - -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. - -## ⚠ Missing credentials must stop the app from starting - -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. - -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. - -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: - -```csharp -builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) - .ValidateDataAnnotations() // [Required] on each credential property - .ValidateOnStart(); // throws during startup, not on first request -``` - -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: - -```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) - throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); -``` - -Three rules for the message it fails with: - -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. - -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +Every operation on this API is public: each endpoint uses `NoneAuthScheme` and sends no credential. Construct the client and call them directly. ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- There are no credentials to set, and no secret to keep out of source. `AddFrankfurterApiClient(options => ...)` still registers the client for DI — see `dotnet-client-initialization`. + diff --git a/plugins/frankfurter/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/frankfurter/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/frankfurter/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/frankfurter/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/frankfurter/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/frankfurter/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/frankfurter/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/frankfurter/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/frankfurter/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/frankfurter/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/frankfurter/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/frankfurter/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/frankfurter/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/frankfurter/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/frankfurter/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/frankfurter/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/frankfurter/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/frankfurter/skills/dotnet/dotnet-getting-started/SKILL.md index 4706f0105..f8751170e 100644 --- a/plugins/frankfurter/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/frankfurter/skills/dotnet/dotnet-getting-started/SKILL.md @@ -1,17 +1,17 @@ --- name: "dotnet-getting-started" -description: "Frankfurter API .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-frankfurter is the workflow that uses it." +description: "Frankfurter API .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-frankfurter-api is the workflow that uses it." --- # Getting started with the Frankfurter API .NET SDK -> **Who this skill is for.** This is the **map layer**: load it to ground every Frankfurter API SDK fact. `dotnet-integrate-frankfurter` is the workflow that drives it — you collect the contracts for the work in scope into `frankfurter-api-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. +> **Who this skill is for.** This is the **map layer**: load it to ground every Frankfurter API SDK fact. `dotnet-integrate-frankfurter-api` is the workflow that drives it — you collect the contracts for the work in scope into `frankfurter-api-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated .NET SDK (auth, calling endpoints, models, error handling, retries, testing), see the companion API-agnostic skills: `dotnet-client-initialization`, `dotnet-authentication`, `dotnet-calling-endpoints`, `dotnet-models`, `dotnet-error-handling`, `dotnet-configuration-resilience`, `dotnet-testing`. **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity @@ -26,9 +26,9 @@ This is the **SDK-specific** entry point. For general patterns that apply to any | Auth | None — this SDK generates no authentication | | Environments | `ServerEnvironment.Production` *(default)* → `https://api.frankfurter.dev/v2` | | Log environment variable | `FRANKFURTERAPICLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | -The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-frankfurter` makes that binding); the **Integration workflow** below says which governs which step. +The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-frankfurter-api` makes that binding); the **Integration workflow** below says which governs which step. ## Namespaces (using-directives) @@ -44,22 +44,24 @@ Models, enums, union types and the generated error types each sit in their own c ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Frankfurter API, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/FrankfurterApi/Models/`, `src/FrankfurterApi/Errors/` and `src/FrankfurterApi/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/FrankfurterApi/Models/`, enums `src/FrankfurterApi/Models/Enums/`, typed errors `src/FrankfurterApi/Errors/`, request records `src/FrankfurterApi/Requests/`, or `src/FrankfurterApi/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: @@ -69,7 +71,7 @@ Keep lookups cheap — the rules that keep a session's context small: ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/frankfurter-api-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/frankfurter-api-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. **Clone once, up front**, into a fresh timestamped folder under `/frankfurter-api-sdk-src/`, and reuse that folder for the rest of your session: @@ -87,30 +89,31 @@ git clone --filter=blob:none --branch main https://github.com/context-plugins/fr # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/frankfurter-api-sdk-src/`, **never** in the project repo, and its path never goes into `frankfurter-api-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `frankfurter-api-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `FrankfurterApiClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/FrankfurterApi/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/FrankfurterApi/Models/` (+ `src/FrankfurterApi/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/FrankfurterApi/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/FrankfurterApi/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/FrankfurterApi/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/FrankfurterApi/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/FrankfurterApi/Servers/`, `src/FrankfurterApi/FrankfurterApiClient.cs`, `src/FrankfurterApi/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Frankfurter API may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything @@ -126,17 +129,17 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/FrankfurterApi/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step -You loaded every companion the sheet named before starting (per `dotnet-integrate-frankfurter`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: +You loaded every companion the sheet named before starting (per `dotnet-integrate-frankfurter-api`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: 1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new FrankfurterApiClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) 2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `FrankfurterApiClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/frankfurter/skills/dotnet/dotnet-integrate-frankfurter/SKILL.md b/plugins/frankfurter/skills/dotnet/dotnet-integrate-frankfurter-api/SKILL.md similarity index 65% rename from plugins/frankfurter/skills/dotnet/dotnet-integrate-frankfurter/SKILL.md rename to plugins/frankfurter/skills/dotnet/dotnet-integrate-frankfurter-api/SKILL.md index 73ce76283..b04bdd2d7 100644 --- a/plugins/frankfurter/skills/dotnet/dotnet-integrate-frankfurter/SKILL.md +++ b/plugins/frankfurter/skills/dotnet/dotnet-integrate-frankfurter-api/SKILL.md @@ -1,125 +1,173 @@ ---- -name: "dotnet-integrate-frankfurter" -description: "MANDATORY FIRST STEP for Frankfurter API .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Frankfurter API in C# — or when a Frankfurter API .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file frankfurter-api-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." ---- - -# Frankfurter API .NET SDK — Workflow (map + skills) - -You are the Frankfurter API .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. - -**Scope guard:** the APIMatic-generated Frankfurter API **.NET SDK** (root namespace `FrankfurterApi`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. - -## Grounding rules - -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `frankfurter-api-plan.md` — the plan must stay portable. -- **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. -- **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. -- **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. -- **The sheet records Frankfurter API's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. -- **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. - -## Workflow - -**If the user opens with a reported SDK error or unexpected Frankfurter API behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: - -### Step 1 — Plan first: write `frankfurter-api-plan.md` (always, for any implementation work) - -Your FIRST deliverable is `frankfurter-api-plan.md` at `/frankfurter-api-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. - -#### How to ground (map-first, one pass) - -1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. -3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. -4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. -5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. - -#### Prerequisites you can run alongside - -These need no SDK knowledge and touch no project file, so do them before or alongside the map work: - -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); -- the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; -- locating the SDK reference and its version in the project; -- credentials/environment verification (per the task's secret-handling rules); -- setting up your task tracking. - -Never use this prerequisite work as a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. - -#### HARD GATE - -**No project-file creation or edits until `frankfurter-api-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. - -#### The `frankfurter-api-plan.md` format (keep it tight — tables, not prose) - -1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. -3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. -6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. -7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. - -| # | Concern | The decision the plan must record | -| --- | --- | --- | -| 1 | **Credential fail-fast** | Where credentials are bound, and that the host refuses to start when one is missing or blank — rather than discovering it as a 401 on the first call in production. A multi-part credential needs **every** part checked, because a blank part is not a missing one. | -| 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | -| 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | -| 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | -| 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | -| 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | -| 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | - -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. - -Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. - -### Step 2 — Required reading (do this before you write any code) - -The contract sheet ends with a **REQUIRED READING** block, and its rows carry inline `MUST load ` pointers. **Load every `dotnet-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. If the sheet names none, load `dotnet-error-handling` anyway — every integration writes an error boundary. - -These are API-agnostic usage skills; loading them is not the same as reading the map. Contract *facts* still come only from the sheet or a map lookup. - -Before implementing, check the plan's **Assumptions & Blockers** section: - -- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `frankfurter-api-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. -- Minor assumptions only → proceed. - -Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. - -### Step 3 — Implement from the contract sheet - -1. Read `frankfurter-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. -2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. -3. After every change: `dotnet build`; fix non-SDK errors yourself. -4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `FrankfurterApi.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. - -### Step 4 — Fixing SDK errors (map-first, in place) - -A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `FrankfurterApi.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. - -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. -3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". -4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). -5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. -6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. - -If a fix corrects a row in `frankfurter-api-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. - -### Step 5 — Pure questions - -A standalone Frankfurter API question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. - -## Anti-patterns — never do these - -- **Never write a Frankfurter API/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** -- **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. -- **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. -- **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search Frankfurter API for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `FrankfurterApi` is what the generated source declares, not what public docs describe. -- **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. - +--- +name: "dotnet-integrate-frankfurter-api" +description: "MANDATORY FIRST STEP for Frankfurter API .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Frankfurter API in C# — or when a Frankfurter API .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file frankfurter-api-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." +--- + +# Frankfurter API .NET SDK — Workflow (map + skills) + +You are the Frankfurter API .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. + +**Scope guard:** the APIMatic-generated Frankfurter API **.NET SDK** (root namespace `FrankfurterApi`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. + +## Grounding rules + +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `frankfurter-api-plan.md` never carries its path — the plan must stay portable. +- **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. +- **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. +- **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. +- **The sheet records Frankfurter API's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. +- **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. + +## Workflow + +**If the user opens with a reported SDK error or unexpected Frankfurter API behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: + +### Step 1 — Plan first: write `frankfurter-api-plan.md` (always, for any implementation work) + +Your FIRST deliverable is `frankfurter-api-plan.md` at `/frankfurter-api-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. + +#### How to ground (map-first, one pass) + +1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. +3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. +4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. +5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. + +#### Prerequisites you can run alongside + +These need no SDK knowledge and touch no project file, so do them before or alongside the map work: + +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; +- `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; +- locating the SDK reference and its version in the project; +- credentials/environment verification (per the task's secret-handling rules); +- setting up your task tracking. + +Never use this prerequisite work as a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. + +#### HARD GATE + +**No project-file creation or edits until `frankfurter-api-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. + +#### The `frankfurter-api-plan.md` format (keep it tight — tables, not prose) + +1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + +3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. +7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. + +| # | Concern | The decision the plan must record | +| --- | --- | --- | +| 1 | **Credential fail-fast** | Where credentials are bound, and that the host refuses to start when one is missing or blank — rather than discovering it as a 401 on the first call in production. A multi-part credential needs **every** part checked, because a blank part is not a missing one. | +| 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | +| 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | +| 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | +| 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | +| 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. + +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. + +Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. + +### Step 2 — Required reading (do this before you write any code) + +The contract sheet ends with a **REQUIRED READING** block, and its rows carry inline `MUST load ` pointers. **Load every `dotnet-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. If the sheet names none, load `dotnet-error-handling` anyway — every integration writes an error boundary. + +These are API-agnostic usage skills; loading them is not the same as reading the map. Contract *facts* still come only from the sheet or a map lookup. + +Before implementing, check the plan's **Assumptions & Blockers** section: + +- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `frankfurter-api-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. +- Minor assumptions only → proceed. + +Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. + +### Step 3 — Implement from the contract sheet + +1. Read `frankfurter-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. +2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. +3. After every change: `dotnet build`; fix non-SDK errors yourself. +4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `FrankfurterApi.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. + +### Step 4 — Fixing SDK errors (map-first, in place) + +A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `FrankfurterApi.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. + +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/FrankfurterApi/Models/{Type}.cs`, `src/FrankfurterApi/Models/Enums/{Type}.cs`, `src/FrankfurterApi/Errors/{Type}.cs`, `src/FrankfurterApi/Requests/{Type}.cs`, or `src/FrankfurterApi/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. +3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". +4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). +5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. +6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. + +If a fix corrects a row in `frankfurter-api-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. + +### Step 5 — Pure questions + +A standalone Frankfurter API question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. + +## Anti-patterns — never do these + +- **Never write a Frankfurter API/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** +- **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. +- **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. +- **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. +- **Do not web-search Frankfurter API for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `FrankfurterApi` is what the generated source declares, not what public docs describe. +- **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. + diff --git a/plugins/frankfurter/skills/dotnet/dotnet-models/SKILL.md b/plugins/frankfurter/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/frankfurter/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/frankfurter/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/frankfurter/skills/dotnet/dotnet-models/reference.md b/plugins/frankfurter/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/frankfurter/skills/dotnet/dotnet-models/reference.md +++ b/plugins/frankfurter/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/frankfurter/skills/dotnet/dotnet-testing/SKILL.md b/plugins/frankfurter/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/frankfurter/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/frankfurter/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/frankfurter/skills/python/python-authentication/SKILL.md b/plugins/frankfurter/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/frankfurter/skills/python/python-authentication/SKILL.md +++ b/plugins/frankfurter/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/frankfurter/skills/python/python-calling-endpoints/SKILL.md b/plugins/frankfurter/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/frankfurter/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/frankfurter/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/frankfurter/skills/python/python-client-initialization/SKILL.md b/plugins/frankfurter/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/frankfurter/skills/python/python-client-initialization/SKILL.md +++ b/plugins/frankfurter/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/frankfurter/skills/python/python-configuration-resilience/SKILL.md b/plugins/frankfurter/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/frankfurter/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/frankfurter/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/frankfurter/skills/python/python-error-handling/SKILL.md b/plugins/frankfurter/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/frankfurter/skills/python/python-error-handling/SKILL.md +++ b/plugins/frankfurter/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/frankfurter/skills/python/python-event-streams/SKILL.md b/plugins/frankfurter/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/frankfurter/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/frankfurter/skills/python/python-file-handling/SKILL.md b/plugins/frankfurter/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/frankfurter/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/frankfurter/skills/python/python-getting-started/SKILL.md b/plugins/frankfurter/skills/python/python-getting-started/SKILL.md index 8ac1c8fae..a0ef51d79 100644 --- a/plugins/frankfurter/skills/python/python-getting-started/SKILL.md +++ b/plugins/frankfurter/skills/python/python-getting-started/SKILL.md @@ -5,7 +5,7 @@ description: "Frankfurter API Python SDK identity and lookup layer (Python only) # Getting started with the Frankfurter API Python SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Frankfurter API Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-frankfurter` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Frankfurter API Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-frankfurter-api` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated Python SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `python-client-initialization`, `python-authentication`, `python-calling-endpoints`, `python-models`, `python-error-handling`, `python-configuration-resilience` and `python-testing`. @@ -25,12 +25,12 @@ Verified against `frankfurter_api/` and `pyproject.toml` of the generated packag | Version | `2.1.1` | | Sync client class | `FrankfurterApiClient` (alias `Client`) | | Async client class | `AsyncFrankfurterApiClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`), and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`) · `retry_options`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | None — the spec declares no security scheme | | Environments | **no environment enum** — one `base_url` string, defaulting to `https://api.frankfurter.dev/v2` | | Base-URL config | `ServerConfig` (`frankfurter_api/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | | Surface | 5 operations, all directly on the client — no controller groups; the map's single page is `map/operations/client.md` · 12 models · 0 unions · 4 enums · 3 per-operation error unions | @@ -39,13 +39,18 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash pip install "frankfurter-api @ git+https://github.com/context-plugins/frankfurter-python-sdk.git@main" +python -c "import frankfurter_api, pathlib; print(pathlib.Path(frankfurter_api.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules @@ -97,15 +102,15 @@ The API declares no security scheme, so the client takes no credentials keyword The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `frankfurter_api/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `frankfurter_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `frankfurter_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/frankfurter-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. Every `Source` path on the map is relative to that SDK root, so `frankfurter_api/models/currency.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. @@ -125,7 +130,7 @@ Read the one module that owns the fact **inside the installed package**. Locate python -c "import frankfurter_api, pathlib; print(pathlib.Path(frankfurter_api.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/frankfurter_api` on Windows, `.venv/lib/python3.*/site-packages/frankfurter_api` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/frankfurter_api` on Windows, `.venv/lib/python3.*/site-packages/frankfurter_api` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -133,7 +138,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/fr | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -143,7 +148,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/fr | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | | An operation's error mapper (status → schema) | `errors/_error.py` | @@ -159,13 +164,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. This SDK declares no scheme, so there is no credentials keyword to set. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`), so an unknown wire value passes through as a plain `str` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -181,8 +186,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 3. `UnprocessableEntity1` — 2 operations (`get_rate` · `get_rates`); distinguishing members *none required* 4. *(none)* — `get_currencies` · `get_providers`: no typed arm, so `.error` is always `RawError` 5. So `isinstance(e.error, NotFound1)` matches only 3 of 5 operations. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 5 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which host the `base_url` selects**, because omitting it is silently the default. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 5 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which host the `base_url` selects**, because omitting it is silently the default. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/frankfurter/skills/python/python-integrate-frankfurter/SKILL.md b/plugins/frankfurter/skills/python/python-integrate-frankfurter-api/SKILL.md similarity index 81% rename from plugins/frankfurter/skills/python/python-integrate-frankfurter/SKILL.md rename to plugins/frankfurter/skills/python/python-integrate-frankfurter-api/SKILL.md index 653ec9150..a35bb9052 100644 --- a/plugins/frankfurter/skills/python/python-integrate-frankfurter/SKILL.md +++ b/plugins/frankfurter/skills/python/python-integrate-frankfurter-api/SKILL.md @@ -1,81 +1,97 @@ ---- -name: "python-integrate-frankfurter" -description: "MANDATORY FIRST STEP for Frankfurter API Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Frankfurter API in Python — or when a Frankfurter API Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." ---- - -# Frankfurter API Python SDK — Integration workflow (lookup layer + contract sheet) - -You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. - -## Your lookup layer - -- **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. -- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import frankfurter_api, pathlib; print(pathlib.Path(frankfurter_api.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) -- **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. -- **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. - -**Scope guard:** the APIMatic-generated Frankfurter API **Python SDK** (import root `frankfurter_api`, distribution `frankfurter-api`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. - -## Workflow - -**If the user opens with a reported SDK error or unexpected Frankfurter API behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: - -### Step 1 — Plan first (always, for any implementation work) - -Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/frankfurter-api-plan.md` — that absolute path, not a location you pick later. - -**Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - -- the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `frankfurter-api` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; -- a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — including which host `base_url` will select, since omitting it is silent; -- **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; -- setting up your task tracking. - -Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. - -**HARD GATE — no project-file creation or edits until:** `frankfurter-api-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. - -**What the gate does not cover:** creating or activating a virtual environment, installing `frankfurter-api` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. - -### Step 1c — Required reading (do this before you write any code) - -End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. - -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `python-error-handling` always (every integration writes an error boundary); `python-client-initialization` before the client is constructed; `python-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes the transport. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. - -These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a module in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. - -Before implementing, check the plan's **Assumptions & Blockers** section: - -- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `frankfurter-api-plan.md` in place. -- Minor assumptions only → proceed. - -Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. - -### Step 2 — Implement from the contract sheet - -1. Read `frankfurter-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). -3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. -4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. -5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `frankfurter_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. - -### Step 3 — Answering pure questions - -A standalone Frankfurter API question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. - -## Anti-patterns — never do these - -- **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. -- **Never write a Frankfurter API/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Don't web-search Frankfurter API topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Frankfurter API docs describe the REST API, not this SDK's generated surface, and the two disagree on names. -- **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. -- **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. -- **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. -- **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. - +--- +name: "python-integrate-frankfurter-api" +description: "MANDATORY FIRST STEP for Frankfurter API Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Frankfurter API in Python — or when a Frankfurter API Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." +--- + +# Frankfurter API Python SDK — Integration workflow (lookup layer + contract sheet) + +You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. + +## Your lookup layer + +- **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. +- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import frankfurter_api, pathlib; print(pathlib.Path(frankfurter_api.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. +- **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. + +**Scope guard:** the APIMatic-generated Frankfurter API **Python SDK** (import root `frankfurter_api`, distribution `frankfurter-api`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. + +## Workflow + +**If the user opens with a reported SDK error or unexpected Frankfurter API behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: + +### Step 1 — Plan first (always, for any implementation work) + +Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/frankfurter-api-plan.md` — that absolute path, not a location you pick later. + +**Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** + +- the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; +- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `frankfurter-api` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; +- a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; +- credentials/environment verification (per the task's secret-handling rules) — including which host `base_url` will select, since omitting it is silent; +- **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; +- setting up your task tracking. + +Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. + +**HARD GATE — no project-file creation or edits until:** `frankfurter-api-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. + +**What the gate does not cover:** creating or activating a virtual environment, installing `frankfurter-api` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +### Step 1c — Required reading (do this before you write any code) + +End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. + +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `python-error-handling` always (every integration writes an error boundary); `python-client-initialization` before the client is constructed; `python-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes the transport. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. + +These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a module in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. + +Before implementing, check the plan's **Assumptions & Blockers** section: + +- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `frankfurter-api-plan.md` in place. +- Minor assumptions only → proceed. + +Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. + +### Step 2 — Implement from the contract sheet + +1. Read `frankfurter-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. +2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). +3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. +4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. +5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `frankfurter_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. + +### Step 3 — Answering pure questions + +A standalone Frankfurter API question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. + +## Anti-patterns — never do these + +- **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. +- **Never write a Frankfurter API/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** +- **Don't web-search Frankfurter API topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Frankfurter API docs describe the REST API, not this SDK's generated surface, and the two disagree on names. +- **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. +- **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. +- **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. +- **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. + diff --git a/plugins/frankfurter/skills/python/python-models/SKILL.md b/plugins/frankfurter/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/frankfurter/skills/python/python-models/SKILL.md +++ b/plugins/frankfurter/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/frankfurter/skills/python/python-models/reference.md b/plugins/frankfurter/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/frankfurter/skills/python/python-models/reference.md +++ b/plugins/frankfurter/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/frankfurter/skills/python/python-testing/SKILL.md b/plugins/frankfurter/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/frankfurter/skills/python/python-testing/SKILL.md +++ b/plugins/frankfurter/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/frankfurter/skills/typescript/typescript-authentication/SKILL.md b/plugins/frankfurter/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/frankfurter/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/frankfurter/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/frankfurter/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/frankfurter/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/frankfurter/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/frankfurter/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/frankfurter/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/frankfurter/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/frankfurter/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/frankfurter/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/frankfurter/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/frankfurter/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/frankfurter/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/frankfurter/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/frankfurter/skills/typescript/typescript-error-handling/SKILL.md b/plugins/frankfurter/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/frankfurter/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/frankfurter/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/frankfurter/skills/typescript/typescript-getting-started/SKILL.md b/plugins/frankfurter/skills/typescript/typescript-getting-started/SKILL.md index 456121235..395b772f9 100644 --- a/plugins/frankfurter/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/frankfurter/skills/typescript/typescript-getting-started/SKILL.md @@ -5,7 +5,7 @@ description: "Frankfurter API TypeScript SDK identity and lookup layer (TypeScri # Getting started with the Frankfurter API TypeScript SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Frankfurter API TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-frankfurter` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Frankfurter API TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-frankfurter-api` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated TypeScript SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `typescript-client-initialization`, `typescript-authentication`, `typescript-calling-endpoints`, `typescript-models`, `typescript-error-handling`, `typescript-configuration-resilience` and `typescript-testing`. @@ -20,15 +20,17 @@ Verified against `package.json` and `sdk-map.md` of the generated package at ver | API | Frankfurter API | | Package name (what you install, and what you import) | `frankfurter-api` — **not on npm**; built from source (see *Install*) | | Import specifier | `frankfurter-api` — the package root is the **only** entry; deep imports do not resolve | +| Source repository | https://github.com/context-plugins/frankfurter-typescript-sdk (branch `main` — the ref this map documents) | | Version | `2.1.1` (API spec version `2.1.1`) | | Client class | `FrankfurterApiClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new FrankfurterApiClient(clientOptions: Partial = {})` — **every** field is optional, so `new FrankfurterApiClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch`. `timeout` defaults to `60_000` ms | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new FrankfurterApiClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new FrankfurterApiClient()` compiles. Fields: `serverOptions` · `retry` · `fetch`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | None — the spec declares no security schemes | -| Environments | 1 environment (`ServerEnvironment.Production` *(default)*) × 1 server group | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`), defaulting to `https://api.frankfurter.dev/v2` | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Environments | no selector — one environment, so none is emitted; 1 server group | +| Base-URL config | `serverOptions.baseUrl` (`src/servers.ts`), defaulting to `https://api.frankfurter.dev/v2` | +| Log environment variable | `FRANKFURTER_API_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Surface | 5 operations · 0 resources · 12 models · 4 open enums · 3 per-operation error subclasses | @@ -37,22 +39,22 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/frankfurter-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 16 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, 16 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { FrankfurterApiClient, ServerEnvironment, ResponseError, FrankfurterApiError } from "frankfurter-api"; +import { FrankfurterApiClient, ApiError, FrankfurterApiError } from "frankfurter-api"; import type { ClientOptions, Currency } from "frankfurter-api"; ``` @@ -64,21 +66,19 @@ Things the specifier alone will not tell you: Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, every model type) must be imported with `import type`. Under `exactOptionalPropertyTypes`, **omit or spread** an absent optional field rather than assigning `undefined` to it. -## Environments +## Hosts -`ClientOptions.serverEnvironment` selects one environment for the whole client (`src/servers.ts`). `ServerEnvironment` is a `const` object with a derived union type — not a TypeScript `enum` — and unlike the model enums it is **closed**, so only its declared members are assignable. +This API declares one environment, so the SDK has **no environment selector** — there is no `ServerEnvironment` type and no field to set (`src/servers.ts`). The host below is the only one the SDK knows, and the override in the last column is the only way to reach another. -| Group | Environment | Base URL | Override at | -| --- | --- | --- | --- | -| `default` | `production` *(default)* | `https://api.frankfurter.dev/v2` | `serverOptions.default.production.baseUrl` | +| Group | Base URL | Override at | +| --- | --- | --- | +| `default` | `https://api.frankfurter.dev/v2` | `serverOptions.baseUrl` | Consequences to state on every contract sheet that touches configuration: -- Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- ⚠ **This SDK declares exactly one environment**, so reaching any other host is a `serverOptions` override, not an environment value — `serverOptions: { default: { production: { baseUrl: "…" } } }`. Note the key is still `production`: the environment name and the host it points at are now decoupled, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the deployment's intent explicit where the client is built, and verify it. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- ⚠ **This SDK declares exactly one environment**, so it emits no `ServerEnvironment` at all and reaching any other host is a `serverOptions` base-URL override — `serverOptions: { baseUrl: "…" }`. Nothing in the options then records **which** deployment this is, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the intent explicit where the client is built, and verify it. +- An override merges with the built-in default **key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. ## Auth pattern (no schemes) @@ -90,7 +90,7 @@ This SDK groups nothing: every operation is a method on `client` itself, so ther 5 operations are methods on the client itself: `client.getCurrencies` · `client.getCurrency` · `client.getProviders` · `client.getRate` · `client.getRates`. -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** A name that collides inside its namespace is promoted to `RequestParams` instead — no operation in this SDK takes that spelling at this version. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -98,7 +98,7 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: @@ -107,11 +107,11 @@ Locate the installed package before you rely on a lookup: node -e "console.log(require.resolve('frankfurter-api/package.json'))" ``` -Failing that it is at `node_modules/frankfurter-api/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/frankfurter-api/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/frankfurter-api/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -119,7 +119,7 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/frankfurter-api/`: @@ -129,13 +129,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | *no scheme is declared, so no auth module is emitted* | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -149,29 +151,37 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `FRANKFURTER_API_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new FrankfurterApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resolved options and the server resolvers live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new FrankfurterApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resolved options and the server resolvers live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) 2. **Authentication** — load **typescript-authentication** before you set credentials. This SDK declares no scheme, so there is nothing to set. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **no operation resolves to `undefined` — every one of them carries a body**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `FrankfurterApiError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `FrankfurterApiError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `FRANKFURTER_API_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK Beyond the usual signatures and model members, a contract sheet for the Frankfurter API TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. -1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment, `ServerEnvironment.Production`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. +1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment and emits no `ServerEnvironment`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. 2. **no operation resolves to `undefined` — every one of them carries a body** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 3 of 5 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 3 of 5 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/frankfurter/skills/typescript/typescript-integrate-frankfurter/SKILL.md b/plugins/frankfurter/skills/typescript/typescript-integrate-frankfurter-api/SKILL.md similarity index 51% rename from plugins/frankfurter/skills/typescript/typescript-integrate-frankfurter/SKILL.md rename to plugins/frankfurter/skills/typescript/typescript-integrate-frankfurter-api/SKILL.md index 498549f4d..a5b822dec 100644 --- a/plugins/frankfurter/skills/typescript/typescript-integrate-frankfurter/SKILL.md +++ b/plugins/frankfurter/skills/typescript/typescript-integrate-frankfurter-api/SKILL.md @@ -1,6 +1,6 @@ --- -name: "typescript-integrate-frankfurter" -description: "MANDATORY FIRST STEP for Frankfurter API TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Frankfurter API in TypeScript/Node — get currencies, get currency, get providers, get rate, get rates, or when the Frankfurter API TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `frankfurter-api` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (frankfurter-api-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares only ServerEnvironment.Production, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." +name: "typescript-integrate-frankfurter-api" +description: "MANDATORY FIRST STEP for Frankfurter API TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Frankfurter API in TypeScript/Node — get currencies, get currency, get providers, get rate, get rates, or when the Frankfurter API TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `frankfurter-api` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (frankfurter-api-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares one environment and no ServerEnvironment, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." --- # Frankfurter API TypeScript SDK — Integration workflow (lookup layer + contract sheet) @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/frankfurter-api/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('frankfurter-api/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('frankfurter-api/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. @@ -23,14 +23,14 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/frankfurter-api-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/frankfurter-api-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, **module format — ESM or CJS, which changes how the SDK is imported and whether `instanceof` on its error classes is trustworthy**, and the runtime: Node, a bundled browser app, or an edge runtime) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - **establish the toolchain before you need it**: the package manager, whether `frankfurter-api` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; - a baseline run of the project's checks (`tsc --noEmit`, the test runner, the linter — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment (`ServerEnvironment.Production`), so **any other host is a `serverOptions` base-URL override rather than an environment value**; +- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment, so it emits no `ServerEnvironment` at all and **any other host is a `serverOptions` base-URL override rather than an environment value**; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your credential may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; - setting up your task tracking. @@ -40,11 +40,85 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** installing `frankfurter-api` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. @@ -58,13 +132,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp ### Step 2 — Implement from the contract sheet 1. Read `frankfurter-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment — `ServerEnvironment.Production` — so a different host is a `serverOptions` override under a key still named after `production`: the environment name and the host it points at are decoupled, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. +2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment and emits no selector for it, so a different host is a `serverOptions` base-URL override. Nothing in the options then records **which** deployment this is, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. 3. **Build the client once and hold it.** The client holds the resolved options, the server resolvers and the `fetch` the transport uses, so rebuilding one per request throws that state away. There is no `close()` — it owns a `fetch`, not a pool. 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from an operation whose success carries no body. `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `FrankfurterApiError` family, told apart by `err.kind`. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `FRANKFURTER_API_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions @@ -74,7 +150,7 @@ A standalone Frankfurter API question with no code change: look it up in `typesc - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. - **Never write any Frankfurter API or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. - **Don't web-search Frankfurter API topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Frankfurter API docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. diff --git a/plugins/frankfurter/skills/typescript/typescript-models/SKILL.md b/plugins/frankfurter/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/frankfurter/skills/typescript/typescript-models/SKILL.md +++ b/plugins/frankfurter/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/frankfurter/skills/typescript/typescript-models/reference.md b/plugins/frankfurter/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/frankfurter/skills/typescript/typescript-models/reference.md +++ b/plugins/frankfurter/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/frankfurter/skills/typescript/typescript-testing/SKILL.md b/plugins/frankfurter/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/frankfurter/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/frankfurter/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/google-maps/.claude-plugin/plugin.json b/plugins/google-maps/.claude-plugin/plugin.json index 8ab2477ca..461dfde06 100644 --- a/plugins/google-maps/.claude-plugin/plugin.json +++ b/plugins/google-maps/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "google-maps", "displayName": "Google Maps Platform", - "version": "0.1.2", + "version": "0.2.0", "description": "Integrate and consume the APIMatic-generated Google Maps Platform SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/google-maps/.cursor-plugin/plugin.json b/plugins/google-maps/.cursor-plugin/plugin.json index 8ab2477ca..461dfde06 100644 --- a/plugins/google-maps/.cursor-plugin/plugin.json +++ b/plugins/google-maps/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "google-maps", "displayName": "Google Maps Platform", - "version": "0.1.2", + "version": "0.2.0", "description": "Integrate and consume the APIMatic-generated Google Maps Platform SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/google-maps/plugin.json b/plugins/google-maps/plugin.json index 8ab2477ca..461dfde06 100644 --- a/plugins/google-maps/plugin.json +++ b/plugins/google-maps/plugin.json @@ -1,7 +1,7 @@ { "name": "google-maps", "displayName": "Google Maps Platform", - "version": "0.1.2", + "version": "0.2.0", "description": "Integrate and consume the APIMatic-generated Google Maps Platform SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/google-maps/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/google-maps/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..f55cf7148 100644 --- a/plugins/google-maps/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/google-maps/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,245 +1,67 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Google Maps Platform .NET SDK in C# — this API authenticates with an API key in a query parameter, and this skill gives the credential each scheme takes, the GoogleMapsPlatformClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Google Maps Platform .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with an API key in a query parameter. APIMatic surfaces each scheme as a **nullable credentials property** on `GoogleMapsPlatformClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) - -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). - -## Basic auth - -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` - -Sends `Authorization: Basic base64(username:password)`. - -## Bearer token - -Set the configured token property on the options class to your access-token string: - -```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; -``` - -Sends `Authorization: Bearer ACCESS_TOKEN`. - -## API key (header, query, or cookie) - -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: - -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` - -## OAuth 2.0 — client credentials (machine-to-machine) - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; - -options.{OAuthProperty} = new OAuth2ClientCredentials -{ - ClientId = "...", - ClientSecret = "...", - Scope = "..." // optional -}; -``` - -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. - -## OAuth 2.0 — authorization code (3-legged, with PKCE) - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; - -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials -{ - ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE - RedirectUri = "https://app.example.com/callback", - Scope = "...", // optional - State = "...", // optional CSRF token - Pkce = PkceMethod.S256, // default; RFC 7636 - PromptForAuthorizationCode = async (authorizationUrl, ct) => - { - // Open/redirect the browser to authorizationUrl, then return the - // authorization code your redirect endpoint received. - return await GetCodeFromUserAsync(authorizationUrl, ct); - } -}; -``` - -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. - -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: - -| PKCE | token request built | no secret ⇒ | +| Scheme | Property on `GoogleMapsPlatformClientOptions` | Credential | | --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | +| API key — `key` query parameter | `ApiKeyAuth` | `string` | -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. +**That table is the entire authentication surface of this SDK.** `GoogleMapsPlatform.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. -## OAuth 2.0 — resource owner password +## API key (query parameter) -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; - -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` - -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. - -### Supplying your own token source - -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. +The key is appended to every request as the `key` query parameter. The parameter name is fixed by the generated scheme — you supply only the value: ```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) +options.ApiKeyAuth = "API_KEY"; ``` -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: - -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). - -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. - -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +⚠ **A query-string credential is logged by everything on the path.** Proxies, CDNs, load balancers and the provider's own access logs record full URLs, and this SDK's request logging is no exception. Treat the key as exposed to your infrastructure, and rotate it on that basis. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `GoogleMapsPlatformClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("GoogleMapsPlatformClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.ApiKeyAuth)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "GoogleMapsPlatformClient:ApiKeyAuth is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"GoogleMapsPlatformClient:ApiKeyAuth is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +Check every value the schemes actually need. For this SDK that is: + +| Value to validate | Feeds `GoogleMapsPlatformClientOptions` property | +| --- | --- | +| `ApiKeyAuth` | `ApiKeyAuth` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddGoogleMapsPlatformClient(options => ...)` callback when registering via DI. `GoogleMapsPlatformClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddGoogleMapsPlatformClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/google-maps/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/google-maps/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/google-maps/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/google-maps/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/google-maps/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/google-maps/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/google-maps/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/google-maps/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/google-maps/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/google-maps/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/google-maps/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/google-maps/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/google-maps/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/google-maps/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/google-maps/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/google-maps/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/google-maps/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/google-maps/skills/dotnet/dotnet-getting-started/SKILL.md index ef75d923d..5c3de0e63 100644 --- a/plugins/google-maps/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/google-maps/skills/dotnet/dotnet-getting-started/SKILL.md @@ -11,7 +11,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity @@ -26,7 +26,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any | Auth | **API key** in the `key` query parameter — set `ApiKeyAuth` | | Environments | `ServerEnvironment.Production` *(default)* → `https://www.googleapis.com`
`ServerEnvironment.Environment2` → `https://maps.googleapis.com`
`ServerEnvironment.Environment3` → `https://roads.googleapis.com` | | Log environment variable | `GOOGLEMAPSPLATFORMCLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-google-maps-platform` makes that binding); the **Integration workflow** below says which governs which step. @@ -44,22 +44,24 @@ Models, enums, union types and the generated error types each sit in their own c ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Google Maps Platform, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/GoogleMapsPlatform/Models/`, `src/GoogleMapsPlatform/Errors/` and `src/GoogleMapsPlatform/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/GoogleMapsPlatform/Models/`, enums `src/GoogleMapsPlatform/Models/Enums/`, typed errors `src/GoogleMapsPlatform/Errors/`, request records `src/GoogleMapsPlatform/Requests/`, or `src/GoogleMapsPlatform/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: @@ -69,7 +71,7 @@ Keep lookups cheap — the rules that keep a session's context small: ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/google-maps-platform-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/google-maps-platform-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. **Clone once, up front**, into a fresh timestamped folder under `/google-maps-platform-sdk-src/`, and reuse that folder for the rest of your session: @@ -87,30 +89,31 @@ git clone --filter=blob:none --branch main https://github.com/context-plugins/go # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/google-maps-platform-sdk-src/`, **never** in the project repo, and its path never goes into `google-maps-platform-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `google-maps-platform-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `GoogleMapsPlatformClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/GoogleMapsPlatform/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/GoogleMapsPlatform/Models/` (+ `src/GoogleMapsPlatform/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/GoogleMapsPlatform/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/GoogleMapsPlatform/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/GoogleMapsPlatform/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/GoogleMapsPlatform/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/GoogleMapsPlatform/Servers/`, `src/GoogleMapsPlatform/GoogleMapsPlatformClient.cs`, `src/GoogleMapsPlatform/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Google Maps Platform may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything @@ -126,7 +129,7 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/GoogleMapsPlatform/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step @@ -134,9 +137,9 @@ You loaded every companion the sheet named before starting (per `dotnet-integrat 1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new GoogleMapsPlatformClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) 2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `GoogleMapsPlatformClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/google-maps/skills/dotnet/dotnet-integrate-google-maps-platform/SKILL.md b/plugins/google-maps/skills/dotnet/dotnet-integrate-google-maps-platform/SKILL.md index 2ed51f30c..c46b992f3 100644 --- a/plugins/google-maps/skills/dotnet/dotnet-integrate-google-maps-platform/SKILL.md +++ b/plugins/google-maps/skills/dotnet/dotnet-integrate-google-maps-platform/SKILL.md @@ -11,7 +11,7 @@ You are the Google Maps Platform .NET SDK specialist for this task — one agent ## Grounding rules -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `google-maps-platform-plan.md` — the plan must stay portable. +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `google-maps-platform-plan.md` never carries its path — the plan must stay portable. - **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. - **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. - **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. @@ -29,7 +29,7 @@ Your FIRST deliverable is `google-maps-platform-plan.md` at ` #### How to ground (map-first, one pass) 1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. 3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. 4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. 5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. @@ -38,7 +38,7 @@ Your FIRST deliverable is `google-maps-platform-plan.md` at ` These need no SDK knowledge and touch no project file, so do them before or alongside the map work: -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); - the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; - locating the SDK reference and its version in the project; @@ -54,10 +54,23 @@ Never use this prerequisite work as a "head start" on implementation: **creating #### The `google-maps-platform-plan.md` format (keep it tight — tables, not prose) 1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + 3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. 6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. 7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. @@ -67,12 +80,46 @@ Never use this prerequisite work as a "head start" on implementation: **creating | 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | | 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | | 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | | 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | | 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | | 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. @@ -95,14 +142,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. 3. After every change: `dotnet build`; fix non-SDK errors yourself. 4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `GoogleMapsPlatform.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. ### Step 4 — Fixing SDK errors (map-first, in place) A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `GoogleMapsPlatform.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/GoogleMapsPlatform/Models/{Type}.cs`, `src/GoogleMapsPlatform/Models/Enums/{Type}.cs`, `src/GoogleMapsPlatform/Errors/{Type}.cs`, `src/GoogleMapsPlatform/Requests/{Type}.cs`, or `src/GoogleMapsPlatform/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. 3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". 4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). 5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. @@ -120,6 +168,6 @@ A standalone Google Maps Platform question with no code change: load `dotnet-get - **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. - **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. - **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search Google Maps Platform for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `GoogleMapsPlatform` is what the generated source declares, not what public docs describe. +- **Do not web-search Google Maps Platform for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `GoogleMapsPlatform` is what the generated source declares, not what public docs describe. - **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. diff --git a/plugins/google-maps/skills/dotnet/dotnet-models/SKILL.md b/plugins/google-maps/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/google-maps/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/google-maps/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/google-maps/skills/dotnet/dotnet-models/reference.md b/plugins/google-maps/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/google-maps/skills/dotnet/dotnet-models/reference.md +++ b/plugins/google-maps/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/google-maps/skills/dotnet/dotnet-testing/SKILL.md b/plugins/google-maps/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/google-maps/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/google-maps/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/google-maps/skills/python/python-authentication/SKILL.md b/plugins/google-maps/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/google-maps/skills/python/python-authentication/SKILL.md +++ b/plugins/google-maps/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/google-maps/skills/python/python-calling-endpoints/SKILL.md b/plugins/google-maps/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/google-maps/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/google-maps/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/google-maps/skills/python/python-client-initialization/SKILL.md b/plugins/google-maps/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/google-maps/skills/python/python-client-initialization/SKILL.md +++ b/plugins/google-maps/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/google-maps/skills/python/python-configuration-resilience/SKILL.md b/plugins/google-maps/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/google-maps/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/google-maps/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/google-maps/skills/python/python-error-handling/SKILL.md b/plugins/google-maps/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/google-maps/skills/python/python-error-handling/SKILL.md +++ b/plugins/google-maps/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/google-maps/skills/python/python-event-streams/SKILL.md b/plugins/google-maps/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/google-maps/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/google-maps/skills/python/python-file-handling/SKILL.md b/plugins/google-maps/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/google-maps/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/google-maps/skills/python/python-getting-started/SKILL.md b/plugins/google-maps/skills/python/python-getting-started/SKILL.md index b82855460..dd1bda8d8 100644 --- a/plugins/google-maps/skills/python/python-getting-started/SKILL.md +++ b/plugins/google-maps/skills/python/python-getting-started/SKILL.md @@ -25,12 +25,12 @@ Verified against `google_maps_platform/` and `pyproject.toml` of the generated p | Version | `1.22.5` | | Sync client class | `GoogleMapsPlatformClient` (alias `Client`) | | Async client class | `AsyncGoogleMapsPlatformClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `environment` · `base_url` · `timeout` (default `30.0`) · `api_key_auth`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Client construction | **keyword-only**: `environment` · `base_url` · `timeout` (default `30.0`) · `retry_options` · `api_key_auth`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | **API key** in the `key` query parameter — set `api_key_auth` | | Environments | 3 environments selected by `environment` (default `"production"`), overridable with `base_url` | | Base-URL config | `ServerConfig` (`google_maps_platform/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | | Surface | 17 operations across 9 controllers · 96 models · 0 unions · 41 enums · 2 per-operation error unions | @@ -39,13 +39,18 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash pip install "google-maps-platform @ git+https://github.com/context-plugins/google-maps-platform-python-sdk.git@main" +python -c "import google_maps_platform, pathlib; print(pathlib.Path(google_maps_platform.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules @@ -131,15 +136,15 @@ The SDK map's controller table carries the same counts and links to a page per c The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `google_maps_platform/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `google_maps_platform/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `google_maps_platform/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/google-maps-platform-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. Every `Source` path on the map is relative to that SDK root, so `google_maps_platform/models/address_component.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. @@ -159,7 +164,7 @@ Read the one module that owns the fact **inside the installed package**. Locate python -c "import google_maps_platform, pathlib; print(pathlib.Path(google_maps_platform.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/google_maps_platform` on Windows, `.venv/lib/python3.*/site-packages/google_maps_platform` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/google_maps_platform` on Windows, `.venv/lib/python3.*/site-packages/google_maps_platform` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -167,7 +172,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/go | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -177,7 +182,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/go | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | | An operation's error mapper (status → schema) | `errors/_error.py` | @@ -194,13 +199,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The one scheme is `api_key_auth=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; **2 operations return `None`**, so `with_raw_response` is the only way to observe their status code; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`), so an unknown wire value passes through as a plain `str` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -216,8 +221,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 3. `NearestRoadsErrorResponse` — 1 operation (`roads_api.nearest_roads`); distinguishing members *none required* 4. *(none)* — `directions_api.directions` · `distance_matrix_api.distance_matrix` · `elevation_api.elevation` · `geocoding_api.geocode` · `places_api.autocomplete` · `places_api.find_place_from_text` · `places_api.nearby_search` · `places_api.place_details` · `places_api.place_photo` · `places_api.query_autocomplete` · `places_api.text_search` · `roads_api.snap_to_roads` · `street_view_api.street_view` · `street_view_api.street_view_metadata` · `time_zone_api.timezone`: no typed arm, so `.error` is always `RawError` 5. So `isinstance(e.error, GeolocationV1Geolocate400Error1)` matches only 1 of 17 operations. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 13 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"` of the 3 declared. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 13 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"` of the 3 declared. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/google-maps/skills/python/python-integrate-google-maps-platform/SKILL.md b/plugins/google-maps/skills/python/python-integrate-google-maps-platform/SKILL.md index 7440dc5dd..7aa1c7e5c 100644 --- a/plugins/google-maps/skills/python/python-integrate-google-maps-platform/SKILL.md +++ b/plugins/google-maps/skills/python/python-integrate-google-maps-platform/SKILL.md @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `python-getting-started` is your **lookup layer - **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import google_maps_platform, pathlib; print(pathlib.Path(google_maps_platform.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import google_maps_platform, pathlib; print(pathlib.Path(google_maps_platform.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) - **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. @@ -40,6 +40,21 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** creating or activating a virtual environment, installing `google-maps-platform` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. @@ -62,8 +77,9 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. 4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `google_maps_platform.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `google_maps_platform.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. ### Step 3 — Answering pure questions diff --git a/plugins/google-maps/skills/python/python-models/SKILL.md b/plugins/google-maps/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/google-maps/skills/python/python-models/SKILL.md +++ b/plugins/google-maps/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/google-maps/skills/python/python-models/reference.md b/plugins/google-maps/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/google-maps/skills/python/python-models/reference.md +++ b/plugins/google-maps/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/google-maps/skills/python/python-testing/SKILL.md b/plugins/google-maps/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/google-maps/skills/python/python-testing/SKILL.md +++ b/plugins/google-maps/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/google-maps/skills/typescript/typescript-authentication/SKILL.md b/plugins/google-maps/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/google-maps/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/google-maps/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/google-maps/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/google-maps/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/google-maps/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/google-maps/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/google-maps/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/google-maps/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/google-maps/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/google-maps/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/google-maps/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/google-maps/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/google-maps/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/google-maps/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/google-maps/skills/typescript/typescript-error-handling/SKILL.md b/plugins/google-maps/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/google-maps/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/google-maps/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/google-maps/skills/typescript/typescript-getting-started/SKILL.md b/plugins/google-maps/skills/typescript/typescript-getting-started/SKILL.md index ee0251688..09c2680c7 100644 --- a/plugins/google-maps/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/google-maps/skills/typescript/typescript-getting-started/SKILL.md @@ -20,15 +20,17 @@ Verified against `package.json` and `sdk-map.md` of the generated package at ver | API | Google Maps Platform | | Package name (what you install, and what you import) | `google-maps-platform` — **not on npm**; built from source (see *Install*) | | Import specifier | `google-maps-platform` — the package root is the **only** entry; deep imports do not resolve | +| Source repository | https://github.com/context-plugins/google-maps-platform-typescript-sdk (branch `main` — the ref this map documents) | | Version | `1.22.5` (API spec version `1.22.5`) | | Client class | `GoogleMapsPlatformClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new GoogleMapsPlatformClient(clientOptions: Partial = {})` — **every** field is optional, so `new GoogleMapsPlatformClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `apiKeyAuth`. `timeout` defaults to `60_000` ms | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new GoogleMapsPlatformClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new GoogleMapsPlatformClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `retry` · `fetch` · `apiKeyAuth`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **API key (query)** — set `ClientOptions.apiKeyAuth` | | Environments | 3 environments (`ServerEnvironment.Production` *(default)*, `ServerEnvironment.Environment2`, `ServerEnvironment.Environment3`) × 1 server group | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`), defaulting to `https://www.googleapis.com` | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Base-URL config | `serverOptions.baseUrl` (`src/servers.ts`), defaulting to `https://www.googleapis.com` | +| Log environment variable | `GOOGLE_MAPS_PLATFORM_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Surface | 17 operations · 9 resources · 96 models · 41 open enums · 2 per-operation error subclasses | @@ -37,22 +39,22 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/google-maps-platform-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 137 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 137 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { GoogleMapsPlatformClient, ServerEnvironment, ResponseError, GoogleMapsPlatformError } from "google-maps-platform"; +import { GoogleMapsPlatformClient, ServerEnvironment, ApiError, GoogleMapsPlatformError } from "google-maps-platform"; import type { ClientOptions, AddressComponent } from "google-maps-platform"; ``` @@ -70,16 +72,16 @@ Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, | Group | Environment | Base URL | Override at | | --- | --- | --- | --- | -| `default` | `production` *(default)* | `https://www.googleapis.com` | `serverOptions.default.production.baseUrl` | -| `default` | `environment2` | `https://maps.googleapis.com` | `serverOptions.default.environment2.baseUrl` | -| `default` | `environment3` | `https://roads.googleapis.com` | `serverOptions.default.environment3.baseUrl` | +| `default` | `production` *(default)* | `https://www.googleapis.com` | `serverOptions.baseUrl` | +| `default` | `environment2` | `https://maps.googleapis.com` | `serverOptions.baseUrl` | +| `default` | `environment3` | `https://roads.googleapis.com` | `serverOptions.baseUrl` | Consequences to state on every contract sheet that touches configuration: - Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. +- An environment value the SDK does not know throws `ConfigurationError` **from the constructor** — every server group is resolved once, as the client is built — so no operation method throws synchronously. ## Auth pattern (1 scheme) @@ -101,7 +103,7 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. See `typescript-authentication` for the full picture. @@ -121,7 +123,7 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | `client.placesApi` | `PlacesApi` | 7 | `autocomplete` · `findPlaceFromText` · `nearbySearch` · `placeDetails` · `placePhoto` · `queryAutocomplete` · `textSearch` | | `client.streetViewApi` | `StreetViewApi` | 2 | `streetView` · `streetViewMetadata` | -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** A name that collides inside its namespace is promoted to `RequestParams` instead — no operation in this SDK takes that spelling at this version. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -129,7 +131,7 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: @@ -138,11 +140,11 @@ Locate the installed package before you rely on a lookup: node -e "console.log(require.resolve('google-maps-platform/package.json'))" ``` -Failing that it is at `node_modules/google-maps-platform/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/google-maps-platform/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/google-maps-platform/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -150,7 +152,7 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/google-maps-platform/`: @@ -160,13 +162,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -180,17 +184,23 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `GOOGLE_MAPS_PLATFORM_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new GoogleMapsPlatformClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new GoogleMapsPlatformClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) 2. **Authentication** — load **typescript-authentication** before you set credentials. The scheme is `apiKeyAuth` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **2 operations resolve to `undefined` (`placesApi.placePhoto`, `streetViewApi.streetView`)**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `GoogleMapsPlatformError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `GoogleMapsPlatformError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `GOOGLE_MAPS_PLATFORM_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK @@ -199,10 +209,12 @@ Beyond the usual signatures and model members, a contract sheet for the Google M 1. **Which host each deployment talks to**, and where that is set. The members are `ServerEnvironment.Production`, `ServerEnvironment.Environment2`, `ServerEnvironment.Environment3`, defaulting to `ServerEnvironment.Production` when the field is unset. 2. **2 operations resolve to `undefined` (`placesApi.placePhoto`, `streetViewApi.streetView`)** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 2 of 17 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 2 of 17 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/google-maps/skills/typescript/typescript-integrate-google-maps-platform/SKILL.md b/plugins/google-maps/skills/typescript/typescript-integrate-google-maps-platform/SKILL.md index d1b0fdd04..4441ae3c0 100644 --- a/plugins/google-maps/skills/typescript/typescript-integrate-google-maps-platform/SKILL.md +++ b/plugins/google-maps/skills/typescript/typescript-integrate-google-maps-platform/SKILL.md @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/google-maps-platform/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('google-maps-platform/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('google-maps-platform/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. @@ -23,7 +23,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/google-maps-platform-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/google-maps-platform-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** @@ -40,11 +40,85 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** installing `google-maps-platform` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. @@ -63,8 +137,10 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from the **2 operations that resolve to `undefined`** (`placesApi.placePhoto`, `streetViewApi.streetView`). `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `GoogleMapsPlatformError` family, told apart by `err.kind` — and an unknown `serverEnvironment` throws `ConfigurationError` out of the client constructor, before any call exists. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `GOOGLE_MAPS_PLATFORM_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions @@ -74,7 +150,7 @@ A standalone Google Maps Platform question with no code change: look it up in `t - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. - **Never write any Google Maps Platform or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. - **Don't web-search Google Maps Platform topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Google Maps Platform docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. diff --git a/plugins/google-maps/skills/typescript/typescript-models/SKILL.md b/plugins/google-maps/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/google-maps/skills/typescript/typescript-models/SKILL.md +++ b/plugins/google-maps/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/google-maps/skills/typescript/typescript-models/reference.md b/plugins/google-maps/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/google-maps/skills/typescript/typescript-models/reference.md +++ b/plugins/google-maps/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/google-maps/skills/typescript/typescript-testing/SKILL.md b/plugins/google-maps/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/google-maps/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/google-maps/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/maxio/.claude-plugin/plugin.json b/plugins/maxio/.claude-plugin/plugin.json index 625b172d6..e0becc2a2 100644 --- a/plugins/maxio/.claude-plugin/plugin.json +++ b/plugins/maxio/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "maxio", "displayName": "Maxio", - "version": "0.2.2", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Maxio Advanced Billing SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/maxio/.cursor-plugin/plugin.json b/plugins/maxio/.cursor-plugin/plugin.json index 625b172d6..e0becc2a2 100644 --- a/plugins/maxio/.cursor-plugin/plugin.json +++ b/plugins/maxio/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "maxio", "displayName": "Maxio", - "version": "0.2.2", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Maxio Advanced Billing SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/maxio/plugin.json b/plugins/maxio/plugin.json index 625b172d6..e0becc2a2 100644 --- a/plugins/maxio/plugin.json +++ b/plugins/maxio/plugin.json @@ -1,7 +1,7 @@ { "name": "maxio", "displayName": "Maxio", - "version": "0.2.2", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Maxio Advanced Billing SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/maxio/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/maxio/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..17c007761 100644 --- a/plugins/maxio/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/maxio/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,245 +1,76 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Maxio .NET SDK in C# — this API authenticates with HTTP Basic, and this skill gives the credential each scheme takes, the MaxioClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Maxio .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with HTTP Basic. APIMatic surfaces each scheme as a **nullable credentials property** on `MaxioClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) +| Scheme | Property on `MaxioClientOptions` | Credential | +| --- | --- | --- | +| HTTP Basic | `BasicAuth` | `BasicAuthCredentials` | -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). +**That table is the entire authentication surface of this SDK.** `Maxio.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. ## Basic auth -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` - -Sends `Authorization: Basic base64(username:password)`. - -## Bearer token - -Set the configured token property on the options class to your access-token string: - -```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; -``` - -Sends `Authorization: Bearer ACCESS_TOKEN`. - -## API key (header, query, or cookie) - -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: - -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` +**API Provider Notes:** -## OAuth 2.0 — client credentials (machine-to-machine) +> The `username` is a Maxio Chargify API key. The `password` is `x`. ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; +using Maxio.Core.Authentication.Basic; -options.{OAuthProperty} = new OAuth2ClientCredentials +options.BasicAuth = new BasicAuthCredentials { - ClientId = "...", - ClientSecret = "...", - Scope = "..." // optional -}; -``` - -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. - -## OAuth 2.0 — authorization code (3-legged, with PKCE) - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; - -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials -{ - ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE - RedirectUri = "https://app.example.com/callback", - Scope = "...", // optional - State = "...", // optional CSRF token - Pkce = PkceMethod.S256, // default; RFC 7636 - PromptForAuthorizationCode = async (authorizationUrl, ct) => - { - // Open/redirect the browser to authorizationUrl, then return the - // authorization code your redirect endpoint received. - return await GetCodeFromUserAsync(authorizationUrl, ct); - } -}; -``` - -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. - -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: - -| PKCE | token request built | no secret ⇒ | -| --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | - -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. - -## OAuth 2.0 — resource owner password - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; - -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional Username = "...", - Password = "...", - Scope = "..." // optional + Password = "..." }; ``` -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. - -### Supplying your own token source - -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. - -```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) -``` - -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: - -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). - -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. - -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +Sends `Authorization: Basic base64(username:password)`. Both halves are required — a username with an empty password is misconfigured, not partially configured. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `MaxioClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("MaxioClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.Username)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "MaxioClient:Username is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"MaxioClient:Username is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. + +Check every value the schemes actually need. For this SDK that is: -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +| Value to validate | Feeds `MaxioClientOptions` property | +| --- | --- | +| `Username` | `BasicAuth` | +| `Password` | `BasicAuth` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddMaxioClient(options => ...)` callback when registering via DI. `MaxioClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddMaxioClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/maxio/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/maxio/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/maxio/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/maxio/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/maxio/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/maxio/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/maxio/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/maxio/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/maxio/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/maxio/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/maxio/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/maxio/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/maxio/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/maxio/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/maxio/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/maxio/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/maxio/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/maxio/skills/dotnet/dotnet-getting-started/SKILL.md index 648812eea..a6e0c1f9f 100644 --- a/plugins/maxio/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/maxio/skills/dotnet/dotnet-getting-started/SKILL.md @@ -1,120 +1,123 @@ --- name: "dotnet-getting-started" -description: "Maxio Advanced Billing .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-maxio is the workflow that uses it." +description: "Maxio .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-maxio is the workflow that uses it." --- -# Getting started with the Maxio Advanced Billing .NET SDK +# Getting started with the Maxio .NET SDK -> **Who this skill is for.** This is the **map layer**: load it to ground every Maxio Advanced Billing SDK fact. `dotnet-integrate-maxio` is the workflow that drives it — you collect the contracts for the work in scope into `maxio-advanced-billing-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. +> **Who this skill is for.** This is the **map layer**: load it to ground every Maxio SDK fact. `dotnet-integrate-maxio` is the workflow that drives it — you collect the contracts for the work in scope into `maxio-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated .NET SDK (auth, calling endpoints, models, error handling, retries, testing), see the companion API-agnostic skills: `dotnet-client-initialization`, `dotnet-authentication`, `dotnet-calling-endpoints`, `dotnet-models`, `dotnet-error-handling`, `dotnet-configuration-resilience`, `dotnet-testing`. **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity | | | | --- | --- | -| API | Maxio Advanced Billing | +| API | Maxio | | NuGet package | *not published to NuGet — see *Install* below* | | Source repo | https://github.com/context-plugins/maxio-csharp-sdk (branch `main` — the ref this map documents) | -| Root namespace | `MaxioAdvancedBilling` (the `using` namespace) | -| Client class | `MaxioAdvancedBillingClient` | -| Options class | `MaxioAdvancedBillingClientOptions` | -| Auth | HTTP **Basic** — set `BasicAuth`
**Bearer** token — set `BearerAuth` | -| Environments | `ServerEnvironment.Us` *(default)* → `Production` `https://{site}.chargify.com`, `Ebb` `https://events.chargify.com/{site}`, `Oauth` `https://{connector}.api.maxio.com`
`ServerEnvironment.Eu` → `Production` `https://{site}.ebilling.maxio.com`, `Ebb` `https://events.chargify.com/{site}`, `Oauth` `https://{connector}.api.maxio.com`
`ServerEnvironment.MaxioApiGateway` → `Production` `https://{connector}.api.maxio.com/api/v1/billing`, `Ebb` `https://events.chargify.com/{site}`, `Oauth` `https://{connector}.api.maxio.com` | -| Log environment variable | `MAXIOADVANCEDBILLINGCLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Root namespace | `Maxio` (the `using` namespace) | +| Client class | `MaxioClient` | +| Options class | `MaxioClientOptions` | +| Auth | HTTP **Basic** — set `BasicAuth` | +| Environments | `ServerEnvironment.Us` *(default)* → `Production` `https://{site}.chargify.com`, `Ebb` `https://events.chargify.com/{site}`
`ServerEnvironment.Eu` → `Production` `https://{site}.ebilling.maxio.com`, `Ebb` `https://events.chargify.com/{site}` | +| Log environment variable | `MAXIOCLIENT_LOG` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-maxio` makes that binding); the **Integration workflow** below says which governs which step. ## Namespaces (using-directives) -The SDK splits its public types across **separate child namespaces**. C# does **not** import child namespaces transitively, so `using MaxioAdvancedBilling.Models;` alone does **not** make enums, union types, or error types visible — you get `CS0103`/`CS0246` ("name/type does not exist") on build. Add a separate `using` for each kind of type you reference: +The SDK splits its public types across **separate child namespaces**. C# does **not** import child namespaces transitively, so `using Maxio.Models;` alone does **not** make enums, union types, or error types visible — you get `CS0103`/`CS0246` ("name/type does not exist") on build. Add a separate `using` for each kind of type you reference: | Type | Namespace | | --- | --- | -| `MaxioAdvancedBillingClient` | `MaxioAdvancedBilling` | -| `MaxioAdvancedBillingClientOptions` | `MaxioAdvancedBilling` | -| `ServerEnvironment` | `MaxioAdvancedBilling.Servers` | +| `MaxioClient` | `Maxio` | +| `MaxioClientOptions` | `Maxio` | +| `ServerEnvironment` | `Maxio.Servers` | Models, enums, union types and the generated error types each sit in their own child namespace. The map's **Namespaces by content type** table maps each source directory to its namespace, so read the namespace off the path the map gives for the type; anything left ambiguous is settled by the `namespace` declaration at the top of the type's own file. ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Maxio, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/Maxio/Models/`, `src/Maxio/Errors/` and `src/Maxio/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/Maxio/Models/`, enums `src/Maxio/Models/Enums/`, typed errors `src/Maxio/Errors/`, request records `src/Maxio/Requests/`, or `src/Maxio/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: -- Collect the contracts for **every** in-scope operation in **one** pass — signatures and error accessors from the map pages, then required fields with wire names and enum values from the declaring files those pages name — into the **contract sheet** in `maxio-advanced-billing-plan.md`, then implement from the sheet. Do not re-open a map page or source file per field, and never re-look-up a fact the sheet already carries. +- Collect the contracts for **every** in-scope operation in **one** pass — signatures and error accessors from the map pages, then required fields with wire names and enum values from the declaring files those pages name — into the **contract sheet** in `maxio-plan.md`, then implement from the sheet. Do not re-open a map page or source file per field, and never re-look-up a fact the sheet already carries. - When you do open a source file, read it **scoped**: with an offset/limit, or a search for the one symbol — never dump a whole file into the conversation. - Never open the SDK's `api-reference.md` — the map supersedes it. ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/maxio-advanced-billing-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/maxio-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. -**Clone once, up front**, into a fresh timestamped folder under `/maxio-advanced-billing-sdk-src/`, and reuse that folder for the rest of your session: +**Clone once, up front**, into a fresh timestamped folder under `/maxio-sdk-src/`, and reuse that folder for the rest of your session: ```bash # Linux/macOS: -dir="${TMPDIR:-/tmp}/maxio-advanced-billing-sdk-src/$(date +%Y%m%d-%H%M%S)" +dir="${TMPDIR:-/tmp}/maxio-sdk-src/$(date +%Y%m%d-%H%M%S)" git clone --filter=blob:none --branch main https://github.com/context-plugins/maxio-csharp-sdk "$dir" # Reuse "$dir" for the rest of your session (it is your clone path). ``` ```powershell # Windows (PowerShell): -$dir = "$env:TEMP\maxio-advanced-billing-sdk-src\$(Get-Date -Format yyyyMMdd-HHmmss)" +$dir = "$env:TEMP\maxio-sdk-src\$(Get-Date -Format yyyyMMdd-HHmmss)" git clone --filter=blob:none --branch main https://github.com/context-plugins/maxio-csharp-sdk $dir # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/maxio-advanced-billing-sdk-src/`, **never** in the project repo, and its path never goes into `maxio-advanced-billing-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `maxio-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `MaxioAdvancedBillingClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/Maxio/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/Maxio/Models/` (+ `src/Maxio/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/Maxio/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/Maxio/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/Maxio/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/Maxio/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/Maxio/Servers/`, `src/Maxio/MaxioClient.cs`, `src/Maxio/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one -The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Maxio Advanced Billing may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. +The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Maxio may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything -Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's Type sources table names it) lists every field with its `[JsonPropertyName]` wire name. If the scope touches such a field, three generator facts decide your logging posture (all in `dotnet-configuration-resilience`): JSON request bodies are logged **verbatim** when `LogRequestBody` is on, with no redaction; form bodies are masked only by deny-list; and leaving `options.Logging.LoggerFactory` unset arms the `MAXIOADVANCEDBILLINGCLIENT_LOG` environment variable, whose `trace` level forces body logging on with no code change and no deploy. So on any build that can carry such a field: `LogRequestBody` stays off, `LoggerFactory` is set explicitly in production, and your own diagnostics never echo a request body on those paths. +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's Type sources table names it) lists every field with its `[JsonPropertyName]` wire name. If the scope touches such a field, three generator facts decide your logging posture (all in `dotnet-configuration-resilience`): JSON request bodies are logged **verbatim** when `LogRequestBody` is on, with no redaction; form bodies are masked only by deny-list; and leaving `options.Logging.LoggerFactory` unset arms the `MAXIOCLIENT_LOG` environment variable, whose `trace` level forces body logging on with no code change and no deploy. So on any build that can carry such a field: `LogRequestBody` stays off, `LoggerFactory` is set explicitly in production, and your own diagnostics never echo a request body on those paths. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. @@ -126,17 +129,17 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/Maxio/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step You loaded every companion the sheet named before starting (per `dotnet-integrate-maxio`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new MaxioAdvancedBillingClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) -2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `MaxioAdvancedBillingClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new MaxioClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) +2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `MaxioClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/maxio/skills/dotnet/dotnet-integrate-maxio/SKILL.md b/plugins/maxio/skills/dotnet/dotnet-integrate-maxio/SKILL.md index 1af43825b..e3ca7a06c 100644 --- a/plugins/maxio/skills/dotnet/dotnet-integrate-maxio/SKILL.md +++ b/plugins/maxio/skills/dotnet/dotnet-integrate-maxio/SKILL.md @@ -1,35 +1,35 @@ --- name: "dotnet-integrate-maxio" -description: "MANDATORY FIRST STEP for Maxio Advanced Billing .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Maxio Advanced Billing in C# — ApiExports, AdvanceInvoice, BillingPortal, ComponentPricePoints, Components, Coupons, CustomFields, Customers, Events, EventsBasedBillingSegments, Insights, Invoices, MaxioGateway, Offers, PaymentProfiles, ProductFamilies, ProductPricePoints, Products, ProformaInvoices, ReasonCodes, ReferralCodes, SalesCommissions, Sites, SubscriptionComponents, SubscriptionGroupInvoiceAccount, SubscriptionGroupStatus, SubscriptionGroups, SubscriptionInvoiceAccount, SubscriptionNotes, SubscriptionProducts, SubscriptionRenewals, SubscriptionStatus, Subscriptions, WebhooksApi — or when a Maxio Advanced Billing .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file maxio-advanced-billing-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." +description: "MANDATORY FIRST STEP for Maxio .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Maxio in C# — ApiExports, AdvanceInvoice, BillingPortal, ComponentFeatures, ComponentPricePoints, Components, Coupons, CustomFields, Customers, Entitlements, Events, EventsBasedBillingSegments, FeatureTemplates, Insights, Invoices, Offers, PaymentProfiles, ProductFamilies, ProductFeatures, ProductPricePoints, Products, ProformaInvoices, ReasonCodes, ReferralCodes, SalesCommissions, Sites, SubscriptionComponents, SubscriptionGroupInvoiceAccount, SubscriptionGroupStatus, SubscriptionGroups, SubscriptionInvoiceAccount, SubscriptionNotes, SubscriptionProducts, SubscriptionRenewals, SubscriptionStatus, Subscriptions, WebhooksApi — or when a Maxio .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file maxio-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." --- -# Maxio Advanced Billing .NET SDK — Workflow (map + skills) +# Maxio .NET SDK — Workflow (map + skills) -You are the Maxio Advanced Billing .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. +You are the Maxio .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. -**Scope guard:** the APIMatic-generated Maxio Advanced Billing **.NET SDK** (root namespace `MaxioAdvancedBilling`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. +**Scope guard:** the APIMatic-generated Maxio **.NET SDK** (root namespace `Maxio`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. ## Grounding rules -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `maxio-advanced-billing-plan.md` — the plan must stay portable. +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `maxio-plan.md` never carries its path — the plan must stay portable. - **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. - **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. - **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. -- **The sheet records Maxio Advanced Billing's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. +- **The sheet records Maxio's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. - **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. ## Workflow -**If the user opens with a reported SDK error or unexpected Maxio Advanced Billing behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: +**If the user opens with a reported SDK error or unexpected Maxio behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: -### Step 1 — Plan first: write `maxio-advanced-billing-plan.md` (always, for any implementation work) +### Step 1 — Plan first: write `maxio-plan.md` (always, for any implementation work) -Your FIRST deliverable is `maxio-advanced-billing-plan.md` at `/maxio-advanced-billing-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. +Your FIRST deliverable is `maxio-plan.md` at `/maxio-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. #### How to ground (map-first, one pass) 1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. 3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. 4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. 5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. @@ -38,7 +38,7 @@ Your FIRST deliverable is `maxio-advanced-billing-plan.md` at ``** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. 6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. 7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. @@ -67,12 +80,46 @@ Never use this prerequisite work as a "head start" on implementation: **creating | 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | | 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | | 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | | 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | | 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | | 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. @@ -84,42 +131,43 @@ These are API-agnostic usage skills; loading them is not the same as reading the Before implementing, check the plan's **Assumptions & Blockers** section: -- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `maxio-advanced-billing-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. +- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `maxio-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. - Minor assumptions only → proceed. Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. ### Step 3 — Implement from the contract sheet -1. Read `maxio-advanced-billing-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. +1. Read `maxio-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. 2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. 3. After every change: `dotnet build`; fix non-SDK errors yourself. -4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `MaxioAdvancedBilling.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. +4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `Maxio.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. ### Step 4 — Fixing SDK errors (map-first, in place) -A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `MaxioAdvancedBilling.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. +A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `Maxio.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/Maxio/Models/{Type}.cs`, `src/Maxio/Models/Enums/{Type}.cs`, `src/Maxio/Errors/{Type}.cs`, `src/Maxio/Requests/{Type}.cs`, or `src/Maxio/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. 3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". 4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). 5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. 6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. -If a fix corrects a row in `maxio-advanced-billing-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. +If a fix corrects a row in `maxio-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. ### Step 5 — Pure questions -A standalone Maxio Advanced Billing question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. +A standalone Maxio question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. ## Anti-patterns — never do these -- **Never write a Maxio Advanced Billing/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** +- **Never write a Maxio/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** - **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. - **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. - **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search Maxio Advanced Billing for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `MaxioAdvancedBilling` is what the generated source declares, not what public docs describe. +- **Do not web-search Maxio for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `Maxio` is what the generated source declares, not what public docs describe. - **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. diff --git a/plugins/maxio/skills/dotnet/dotnet-models/SKILL.md b/plugins/maxio/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/maxio/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/maxio/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/maxio/skills/dotnet/dotnet-models/reference.md b/plugins/maxio/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/maxio/skills/dotnet/dotnet-models/reference.md +++ b/plugins/maxio/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/maxio/skills/dotnet/dotnet-testing/SKILL.md b/plugins/maxio/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/maxio/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/maxio/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/maxio/skills/python/python-authentication/SKILL.md b/plugins/maxio/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/maxio/skills/python/python-authentication/SKILL.md +++ b/plugins/maxio/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/maxio/skills/python/python-calling-endpoints/SKILL.md b/plugins/maxio/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/maxio/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/maxio/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/maxio/skills/python/python-client-initialization/SKILL.md b/plugins/maxio/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/maxio/skills/python/python-client-initialization/SKILL.md +++ b/plugins/maxio/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/maxio/skills/python/python-configuration-resilience/SKILL.md b/plugins/maxio/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/maxio/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/maxio/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/maxio/skills/python/python-error-handling/SKILL.md b/plugins/maxio/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/maxio/skills/python/python-error-handling/SKILL.md +++ b/plugins/maxio/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/maxio/skills/python/python-event-streams/SKILL.md b/plugins/maxio/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/maxio/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/maxio/skills/python/python-file-handling/SKILL.md b/plugins/maxio/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/maxio/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/maxio/skills/python/python-getting-started/SKILL.md b/plugins/maxio/skills/python/python-getting-started/SKILL.md index d07297785..eccc27194 100644 --- a/plugins/maxio/skills/python/python-getting-started/SKILL.md +++ b/plugins/maxio/skills/python/python-getting-started/SKILL.md @@ -1,11 +1,11 @@ --- name: "python-getting-started" -description: "Maxio Advanced Billing Python SDK identity and lookup layer (Python only) — install, import root, base URL/environments, the auth pattern, the SDK map that ships at the SDK root (`sdk-map.md` + `map/operations/`) and how to traverse it, and the module table naming the one file owning each fact the map leaves to the source. Load this before answering any Maxio Advanced Billing Python SDK contract question or writing any SDK code." +description: "Maxio Python SDK identity and lookup layer (Python only) — install, import root, base URL/environments, the auth pattern, the SDK map that ships at the SDK root (`sdk-map.md` + `map/operations/`) and how to traverse it, and the module table naming the one file owning each fact the map leaves to the source. Load this before answering any Maxio Python SDK contract question or writing any SDK code." --- -# Getting started with the Maxio Advanced Billing Python SDK +# Getting started with the Maxio Python SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Maxio Advanced Billing Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-maxio` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Maxio Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-maxio` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated Python SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `python-client-initialization`, `python-authentication`, `python-calling-endpoints`, `python-models`, `python-error-handling`, `python-configuration-resilience` and `python-testing`. @@ -13,53 +13,58 @@ This is the **SDK-specific** entry point. For general patterns that apply to any ## SDK identity -Verified against `maxio_advanced_billing/` and `pyproject.toml` of the generated package at version `1.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. +Verified against `maxio/` and `pyproject.toml` of the generated package at version `1.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. | Fact | Value | | --- | --- | -| API | Maxio Advanced Billing | -| Distribution name (what you install) | `maxio-advanced-billing` — **not on any package index**; installed from source (see *Install*) | -| Import root (what you import) | `maxio_advanced_billing` — not the string you install | +| API | Maxio | +| Distribution name (what you install) | `maxio` — **not on any package index**; installed from source (see *Install*) | +| Import root (what you import) | `maxio` — the same string you install | | Source repository | https://github.com/context-plugins/maxio-python-sdk | | Source branch | `main` | | Version | `1.0` | -| Sync client class | `MaxioAdvancedBillingClient` (alias `Client`) | -| Async client class | `AsyncMaxioAdvancedBillingClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `environment` · `timeout` (default `30.0`) · `server_config` · `basic_auth` · `bearer_auth`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | -| Auth | HTTP **Basic** — set `basic_auth` · **Bearer** token — set `bearer_auth` | -| Environments | 3 environments (default `"us"`) × 3 named servers, through `server_config` | -| Base-URL config | `ServerConfig` (`maxio_advanced_billing/server/server_config.py`), frozen, `extra="forbid"` | +| Sync client class | `MaxioClient` (alias `Client`) | +| Async client class | `AsyncMaxioClient` (alias `AsyncClient`) | +| Client construction | **keyword-only**: `environment` · `timeout` (default `30.0`) · `server_config` · `retry_options` · `basic_auth`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Auth | HTTP **Basic** — set `basic_auth` | +| Environments | 2 environments (default `"us"`) × 2 named servers, through `server_config` | +| Base-URL config | `ServerConfig` (`maxio/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | -| Surface | 250 operations across 34 controllers · 563 models · 90 unions · 98 enums · 166 per-operation error unions | +| Surface | 268 operations across 37 controllers · 576 models · 97 unions · 111 enums · 184 per-operation error unions | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, import roots, the auth *pattern*, the base-URL knob), while the actual integration code comes from the companion skills. Load each one as you reach its step (see **Integration workflow** below) and confirm its types against the installed package. ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash -pip install "maxio-advanced-billing @ git+https://github.com/context-plugins/maxio-python-sdk.git@main" +pip install "maxio @ git+https://github.com/context-plugins/maxio-python-sdk.git@main" +python -c "import maxio, pathlib; print(pathlib.Path(maxio.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules -Python does not re-export child modules transitively, so `from maxio_advanced_billing import models` alone does **not** make enums, error unions, or runtime types reachable. Import each kind of type from the module that owns it. +Python does not re-export child modules transitively, so `from maxio import models` alone does **not** make enums, error unions, or runtime types reachable. Import each kind of type from the module that owns it. -`maxio_advanced_billing/__init__.py` exports exactly 8 names beside the `maxio_advanced_billing.models` subpackage it re-exports: +`maxio/__init__.py` exports exactly 8 names beside the `maxio.models` subpackage it re-exports: ```python -from maxio_advanced_billing import ( +from maxio import ( AsyncClient, - AsyncMaxioAdvancedBillingClient, + AsyncMaxioClient, Client, Environment, - MaxioAdvancedBillingClient, + MaxioClient, ServerConfig, ServerConfigDict, ServerConfigOrDict, @@ -70,30 +75,28 @@ Everything else comes from its own subpackage, and the split matters because eac | What you need | Where it lives | | --- | --- | -| Domain models, their `…Dict` companions | `maxio_advanced_billing.models` | -| Enums (and their open `…OrStr`/`…OrInt` aliases) | `maxio_advanced_billing.models.enums` | -| `ApiError` · `RawError` · `ApiResult` · `RequestOptions` · `BasicAuthCredentials` · `HttpClient` · `SdkBaseModel` · `UNSET` · `Optional` | `maxio_advanced_billing.core` | -| Per-operation error *unions* | `maxio_advanced_billing.errors` (`ActivateSubscriptionErrorBody`, …) | +| Domain models, their `…Dict` companions | `maxio.models` | +| Enums (and their open `…OrStr`/`…OrInt` aliases) | `maxio.models.enums` | +| `ApiError` · `RawError` · `ApiResult` · `RequestOptions` · `BasicAuthCredentials` · `HttpClient` · `SdkBaseModel` · `UNSET` · `Optional` | `maxio.core` | +| Per-operation error *unions* | `maxio.errors` (`ActivateSubscriptionErrorBody`, …) | -`maxio_advanced_billing.core` re-exports its whole public surface (a curated `__all__`), so import from `…core` rather than from the private modules beneath it (`…core.results`, `…core.exceptions`, `…core.auth.schemes`). +`maxio.core` re-exports its whole public surface (a curated `__all__`), so import from `…core` rather than from the private modules beneath it (`…core.results`, `…core.exceptions`, `…core.auth.schemes`). ## Environments and servers -The API declares 3 named servers across 3 environments, so the constructor takes `environment` first, then `timeout`, then `server_config: ServerConfigOrDict | None = None`. The environment selects which set of base URLs the config resolves against: +The API declares 2 named servers across 2 environments, so the constructor takes `environment` first, then `timeout`, then `server_config: ServerConfigOrDict | None = None`. The environment selects which set of base URLs the config resolves against: | `environment=` | Hosting | | --- | --- | | `"us"` *(default)* | Default Advanced Billing environment hosted in US. Valid for the majority of our customers | | `"eu"` | Advanced Billing environment hosted in EU. Use only when you requested EU hosting for your AB account | -| `"maxio_api_gateway"` | Access Advanced Billing through a Maxio API Gateway connector. Authenticate with your connector Bearer token instead of Basic auth. Events-Based Billing ingestion does not go through the gateway and keeps its direct URL | -Each of the 3 servers resolves independently, so a call's host is the pair (server, environment): +Each of the 2 servers resolves independently, so a call's host is the pair (server, environment): -| Server field | `"us"` base URL | `"eu"` base URL | `"maxio_api_gateway"` base URL | -| --- | --- | --- | --- | -| `production` | `https://{site}.chargify.com` | `https://{site}.ebilling.maxio.com` | `https://{connector}.api.maxio.com/api/v1/billing` | -| `ebb` | `https://events.chargify.com/{site}` | `https://events.chargify.com/{site}` | `https://events.chargify.com/{site}` | -| `oauth` | `https://{connector}.api.maxio.com` | `https://{connector}.api.maxio.com` | `https://{connector}.api.maxio.com` | +| Server field | `"us"` base URL | `"eu"` base URL | +| --- | --- | --- | +| `production` | `https://{site}.chargify.com` | `https://{site}.ebilling.maxio.com` | +| `ebb` | `https://events.chargify.com/{site}` | `https://events.chargify.com/{site}` | Consequences to state on every contract sheet that touches configuration: @@ -102,26 +105,18 @@ Consequences to state on every contract sheet that touches configuration: - `ServerConfig` is a frozen pydantic model with `extra="forbid"`: a misspelled keyword raises `ValidationError` at construction rather than being ignored. - `timeout` is validated too — the base client raises `ValueError` for any non-positive value. -## Auth pattern (two schemes) +## Auth pattern (one scheme) HTTP Basic authentication, exposed as the client's `basic_auth=` keyword taking `BasicAuthCredentials` **or a plain dict**. ```python -from maxio_advanced_billing import Client -from maxio_advanced_billing.core import BasicAuthCredentials +from maxio import Client +from maxio.core import BasicAuthCredentials client = Client(basic_auth=BasicAuthCredentials(username="…", password="…")) client = Client(basic_auth={"username": "…", "password": "…"}) # equivalent ``` -A bearer token, exposed as the client's `bearer_auth=` keyword taking a plain string. - -```python -from maxio_advanced_billing import Client - -client = Client(bearer_auth="") -``` - **Every credentials keyword is optional at the type level and that is a trap worth flagging on every sheet.** Omit it and the client is built with `no_auth`: every request goes out unauthenticated. Nothing fails at construction. Most APIs then answer `401`; an API that serves anonymous traffic at all answers `200` and hides the omission entirely — verify the keyword is set rather than waiting for a `401` to tell you. See `python-authentication` for the full picture, including what a *failed token fetch* raises — it is not what a caller expects, and it is the single most common surprise in this SDK. @@ -135,19 +130,22 @@ Controllers and their operation counts (`client.`): | `client.api_exports` | `ApiExports` / `AsyncApiExports` | 9 | `export_invoices` · `export_proforma_invoices` · `export_subscriptions` · `list_exported_invoices` · `list_exported_proforma_invoices` · `list_exported_subscriptions` · … | | `client.advance_invoice` | `AdvanceInvoice` / `AsyncAdvanceInvoice` | 3 | `issue_advance_invoice` · `read_advance_invoice` · `void_advance_invoice` | | `client.billing_portal` | `BillingPortal` / `AsyncBillingPortal` | 4 | `enable_billing_portal_for_customer` · `read_billing_portal_link` · `resend_billing_portal_invitation` · `revoke_billing_portal_access` | +| `client.component_features` | `ComponentFeatures` / `AsyncComponentFeatures` | 6 | `create_component_feature` · `list_component_features` · `read_component_feature` · `remove_component_feature` · `restore_component_feature` · `update_component_feature` | | `client.component_price_points` | `ComponentPricePoints` / `AsyncComponentPricePoints` | 12 | `archive_component_price_point` · `bulk_create_component_price_points` · `clone_component_price_point` · `create_component_price_point` · `create_currency_prices` · `list_all_component_price_points` · … | | `client.components` | `Components` / `AsyncComponents` | 12 | `archive_component` · `create_event_based_component` · `create_metered_component` · `create_on_off_component` · `create_prepaid_usage_component` · `create_quantity_based_component` · … | | `client.coupons` | `Coupons` / `AsyncCoupons` | 14 | `archive_coupon` · `create_coupon` · `create_coupon_subcodes` · `create_or_update_coupon_currency_prices` · `delete_coupon_subcode` · `find_coupon` · … | | `client.custom_fields` | `CustomFields` / `AsyncCustomFields` | 9 | `create_metadata` · `create_metafields` · `delete_metadata` · `delete_metafield` · `list_metadata` · `list_metadata_for_resource_type` · … | | `client.customers` | `Customers` / `AsyncCustomers` | 7 | `create_customer` · `delete_customer` · `list_customer_subscriptions` · `list_customers` · `read_customer` · `read_customer_by_reference` · … | +| `client.entitlements` | `Entitlements` / `AsyncEntitlements` | 1 | `read_subscription_entitlements` | | `client.events` | `Events` / `AsyncEvents` | 3 | `list_events` · `list_subscription_events` · `read_events_count` | | `client.events_based_billing_segments` | `EventsBasedBillingSegments` / `AsyncEventsBasedBillingSegments` | 6 | `bulk_create_segments` · `bulk_update_segments` · `create_segment` · `delete_segment` · `list_segments_for_price_point` · `update_segment` | +| `client.feature_templates` | `FeatureTemplates` / `AsyncFeatureTemplates` | 6 | `archive_feature_template` · `create_feature_template` · `list_feature_templates` · `read_feature_template` · `restore_feature_template` · `update_feature_template` | | `client.insights` | `Insights` / `AsyncInsights` | 4 | `list_mrr_movements` · `list_mrr_per_subscription` · `read_mrr` · `read_site_stats` | | `client.invoices` | `Invoices` / `AsyncInvoices` | 19 | `create_invoice` · `delete_invoice` · `issue_invoice` · `list_consolidated_invoice_segments` · `list_credit_notes` · `list_invoice_events` · … | -| `client.maxio_gateway` | `MaxioGateway` / `AsyncMaxioGateway` | 1 | `request_access_token` | | `client.offers` | `Offers` / `AsyncOffers` | 5 | `archive_offer` · `create_offer` · `list_offers` · `read_offer` · `unarchive_offer` | | `client.payment_profiles` | `PaymentProfiles` / `AsyncPaymentProfiles` | 12 | `change_subscription_default_payment_profile` · `change_subscription_group_default_payment_profile` · `create_payment_profile` · `delete_subscription_group_payment_profile` · `delete_subscriptions_payment_profile` · `delete_unused_payment_profile` · … | | `client.product_families` | `ProductFamilies` / `AsyncProductFamilies` | 4 | `create_product_family` · `list_product_families` · `list_products_for_product_family` · `read_product_family` | +| `client.product_features` | `ProductFeatures` / `AsyncProductFeatures` | 6 | `create_product_feature` · `list_product_features` · `read_product_feature` · `remove_product_feature` · `restore_product_feature` · `update_product_feature` | | `client.product_price_points` | `ProductPricePoints` / `AsyncProductPricePoints` | 11 | `archive_product_price_point` · `bulk_create_product_price_points` · `create_product_currency_prices` · `create_product_price_point` · `list_all_product_price_points` · `list_product_price_points` · … | | `client.products` | `Products` / `AsyncProducts` | 6 | `archive_product` · `create_product` · `list_products` · `read_product` · `read_product_by_handle` · `update_product` | | `client.proforma_invoices` | `ProformaInvoices` / `AsyncProformaInvoices` | 10 | `create_consolidated_proforma_invoice` · `create_proforma_invoice` · `create_signup_proforma_invoice` · `deliver_proforma_invoice` · `list_proforma_invoices` · `list_subscription_group_proforma_invoices` · … | @@ -173,24 +171,24 @@ The SDK map's controller table carries the same counts and links to a page per c ## SDK map — look up first, open the module second -The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `maxio_advanced_billing/` source directory, and these two entries: +The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `maxio/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `maxio_advanced_billing/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `maxio/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/maxio-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. -Every `Source` path on the map is relative to that SDK root, so `maxio_advanced_billing/models/ach_agreement.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. +Every `Source` path on the map is relative to that SDK root, so `maxio/models/ach_agreement.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. **The map is the locator; the source modules are the shapes.** Read the map first — signatures, routes, parameter roles, return types, error unions, and which module declares a type are all answered there without opening a single `.py` file. Then open the one module the map names for what it deliberately does not carry: a model's members, an enum's values, a field's wire alias. The map says so itself — *"Shapes live only in the source … Never grep for a type."* -**The map carries shapes; what an operation *means* lives elsewhere.** When *what* to pass depends on meaning — which values a field accepts beyond its type, a rule that couples two fields, what a defaulted parameter actually selects — the map will not settle it. Open the operation's docstring in `maxio_advanced_billing/apis/.py` (or `client.py` where operations sit on the client) and `api-reference.md` at the SDK root for that operation *before* writing the sheet row, and record what you found there. A value you already "know" for a field the map types as a plain `str` is a lookup, not a recall — the memory ban applies to it. +**The map carries shapes; what an operation *means* lives elsewhere.** When *what* to pass depends on meaning — which values a field accepts beyond its type, a rule that couples two fields, what a defaulted parameter actually selects — the map will not settle it. Open the operation's docstring in `maxio/apis/.py` (or `client.py` where operations sit on the client) and `api-reference.md` at the SDK root for that operation *before* writing the sheet row, and record what you found there. A value you already "know" for a field the map types as a plain `str` is a lookup, not a recall — the memory ban applies to it. `sdk-map.md` carries the invariants every operation block assumes, so load it before any `map/operations/` page; the pages are written to be read beside it. @@ -201,10 +199,10 @@ Every `Source` path on the map is relative to that SDK root, so `maxio_advanced_ Read the one module that owns the fact **inside the installed package**. Locate it first: ```bash -python -c "import maxio_advanced_billing, pathlib; print(pathlib.Path(maxio_advanced_billing.__file__).parent)" +python -c "import maxio, pathlib; print(pathlib.Path(maxio.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/maxio_advanced_billing` on Windows, `.venv/lib/python3.*/site-packages/maxio_advanced_billing` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/maxio` on Windows, `.venv/lib/python3.*/site-packages/maxio` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -212,7 +210,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/ma | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -222,7 +220,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/ma | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | | An operation's error mapper (status → schema) | `errors/_error.py` | @@ -239,13 +237,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) -2. **Authentication** — load **python-authentication** before you set credentials. The two schemes are `basic_auth=` and `bearer_auth=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; Load secrets from the environment or a secret store, never hardcode.) -3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; **29 operations return `None`**, so `with_raw_response` is the only way to observe their status code; and the two response modes — raising vs `ApiResult` — behave differently on failure.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +2. **Authentication** — load **python-authentication** before you set credentials. The one scheme is `basic_auth=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; Load secrets from the environment or a secret store, never hardcode.) +3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; **32 operations return `None`**, so `with_raw_response` is the only way to observe their status code; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`/`…OrInt`), so an unknown wire value passes through as a plain `str`/`int` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -253,10 +251,10 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 1. **Sync or async** — which client class, and the reminder that the two do not mix. Plus the `close()`/`aclose()` obligation and where the client is held. 2. **The keyword-only boundary** for each operation: what is positional (path params, sometimes the body) and what sits after `*`. Every keyword-only parameter has a real default, so there is no "must pass `None` explicitly" hazard — say so, so nobody writes defensive `None`s. -3. **The 29 operations that return `None`** — `coupons.delete_coupon_subcode` · `custom_fields.delete_metadata` · `custom_fields.delete_metafield` · `customers.delete_customer` · `events_based_billing_segments.delete_segment` · `invoices.delete_invoice` · `invoices.send_invoice` · `offers.archive_offer` · `offers.unarchive_offer` · `payment_profiles.delete_subscription_group_payment_profile` · `payment_profiles.delete_subscriptions_payment_profile` · `payment_profiles.delete_unused_payment_profile` · `payment_profiles.send_request_update_payment_email` · `proforma_invoices.create_consolidated_proforma_invoice` · `sites.clear_site` · `subscription_components.activate_event_based_component` · `subscription_components.bulk_record_events` · `subscription_components.deactivate_event_based_component` · `subscription_components.delete_prepaid_usage_allocation` · `subscription_components.record_event` · `subscription_components.update_prepaid_usage_allocation_expiration_date` · `subscription_group_status.cancel_delayed_cancellation_for_group` · `subscription_group_status.cancel_subscriptions_in_group` · `subscription_group_status.initiate_delayed_cancellation_for_group` · `subscription_groups.remove_subscription_from_group` · `subscription_invoice_account.deduct_service_credit` · `subscription_notes.delete_subscription_note` · `subscription_renewals.delete_scheduled_renewal_configuration_item` · `subscriptions.override_subscription`. Their raw peers are `ApiResult[None, …]`, so `with_raw_response` is the only way to observe the status code. +3. **The 32 operations that return `None`** — `component_features.remove_component_feature` · `coupons.delete_coupon_subcode` · `custom_fields.delete_metadata` · `custom_fields.delete_metafield` · `customers.delete_customer` · `events_based_billing_segments.delete_segment` · `feature_templates.archive_feature_template` · `invoices.delete_invoice` · `invoices.send_invoice` · `offers.archive_offer` · `offers.unarchive_offer` · `payment_profiles.delete_subscription_group_payment_profile` · `payment_profiles.delete_subscriptions_payment_profile` · `payment_profiles.delete_unused_payment_profile` · `payment_profiles.send_request_update_payment_email` · `product_features.remove_product_feature` · `proforma_invoices.create_consolidated_proforma_invoice` · `sites.clear_site` · `subscription_components.activate_event_based_component` · `subscription_components.bulk_record_events` · `subscription_components.deactivate_event_based_component` · `subscription_components.delete_prepaid_usage_allocation` · `subscription_components.record_event` · `subscription_components.update_prepaid_usage_allocation_expiration_date` · `subscription_group_status.cancel_delayed_cancellation_for_group` · `subscription_group_status.cancel_subscriptions_in_group` · `subscription_group_status.initiate_delayed_cancellation_for_group` · `subscription_groups.remove_subscription_from_group` · `subscription_invoice_account.deduct_service_credit` · `subscription_notes.delete_subscription_note` · `subscription_renewals.delete_scheduled_renewal_configuration_item` · `subscriptions.override_subscription`. Their raw peers are `ApiResult[None, …]`, so `with_raw_response` is the only way to observe the status code. 4. **Required vs `UNSET`** for every model member the task sets, and the fact that `Optional[T]` here is `T | UnsetType` — **not** `typing.Optional`, so `None` is not a legal value for it. -5. **The `ApiError.error` union** for each operation in scope — there are **33** typed error bodies in this SDK, so the union is never uniform. Every union is ` | RawError`: - 1. `ErrorListResponse1` — 91 operations (`advance_invoice.issue_advance_invoice` · `billing_portal.enable_billing_portal_for_customer` · `billing_portal.read_billing_portal_link` · `billing_portal.resend_billing_portal_invitation` · `component_price_points.archive_component_price_point` · `component_price_points.bulk_create_component_price_points` · `component_price_points.clone_component_price_point` · `component_price_points.list_all_component_price_points` · `components.archive_component` · `components.create_event_based_component` · `components.create_metered_component` · `components.create_on_off_component` · `components.create_prepaid_usage_component` · `components.create_quantity_based_component` · `components.update_component` · `components.update_product_family_component` · `coupons.create_coupon` · `coupons.update_coupon` · `invoices.delete_invoice` · `invoices.issue_invoice` · `invoices.preview_customer_information_changes` · `invoices.record_payment_for_invoice` · `invoices.record_payment_for_multiple_invoices` · `invoices.record_payment_for_subscription` · `invoices.refund_invoice` · `invoices.reopen_invoice` · `invoices.send_invoice` · `invoices.update_customer_information` · `invoices.update_invoice` · `invoices.void_invoice` · `offers.list_offers` · `payment_profiles.change_subscription_default_payment_profile` · `payment_profiles.change_subscription_group_default_payment_profile` · `payment_profiles.create_payment_profile` · `payment_profiles.delete_unused_payment_profile` · `payment_profiles.read_one_time_token` · `payment_profiles.send_request_update_payment_email` · `payment_profiles.verify_bank_account` · `product_families.create_product_family` · `product_price_points.archive_product_price_point` · `product_price_points.list_all_product_price_points` · `products.archive_product` · `products.create_product` · `products.update_product` · `proforma_invoices.create_consolidated_proforma_invoice` · `proforma_invoices.create_proforma_invoice` · `proforma_invoices.deliver_proforma_invoice` · `proforma_invoices.preview_proforma_invoice` · `proforma_invoices.void_proforma_invoice` · `reason_codes.create_reason_code` · `reason_codes.list_reason_codes` · `reason_codes.update_reason_code` · `subscription_components.allocate_component` · `subscription_components.allocate_components` · `subscription_components.create_usage` · `subscription_components.list_allocations` · `subscription_group_invoice_account.create_subscription_group_prepayment` · `subscription_group_invoice_account.deduct_subscription_group_service_credit` · `subscription_group_invoice_account.issue_subscription_group_service_credit` · `subscription_group_status.cancel_delayed_cancellation_for_group` · `subscription_group_status.cancel_subscriptions_in_group` · `subscription_group_status.initiate_delayed_cancellation_for_group` · `subscription_group_status.reactivate_subscription_group` · `subscription_groups.remove_subscription_from_group` · `subscription_invoice_account.list_service_credits` · `subscription_notes.create_subscription_note` · `subscription_notes.list_subscription_notes` · `subscription_notes.update_subscription_note` · `subscription_products.migrate_subscription_product` · `subscription_products.preview_subscription_product_migration` · `subscription_renewals.cancel_scheduled_renewal_configuration` · `subscription_renewals.create_scheduled_renewal_configuration` · `subscription_renewals.create_scheduled_renewal_configuration_item` · `subscription_renewals.delete_scheduled_renewal_configuration_item` · `subscription_renewals.lock_in_scheduled_renewal_immediately` · `subscription_renewals.schedule_scheduled_renewal_lock_in` · `subscription_renewals.unpublish_scheduled_renewal_configuration` · `subscription_renewals.update_scheduled_renewal_configuration` · `subscription_renewals.update_scheduled_renewal_configuration_item` · `subscription_status.cancel_dunning` · `subscription_status.initiate_delayed_cancellation` · `subscription_status.pause_subscription` · `subscription_status.preview_renewal` · `subscription_status.reactivate_subscription` · `subscription_status.resume_subscription` · `subscription_status.retry_subscription` · `subscription_status.update_automatic_subscription_resumption` · `subscriptions.create_subscription` · `subscriptions.update_subscription` · `webhooks.create_endpoint` · `webhooks.update_endpoint`); distinguishing members `errors` +5. **The `ApiError.error` union** for each operation in scope — there are **32** typed error bodies in this SDK, so the union is never uniform. Every union is ` | RawError`: + 1. `ErrorListResponse1` — 110 operations (`advance_invoice.issue_advance_invoice` · `billing_portal.enable_billing_portal_for_customer` · `billing_portal.read_billing_portal_link` · `billing_portal.resend_billing_portal_invitation` · `component_features.create_component_feature` · `component_features.list_component_features` · `component_features.read_component_feature` · `component_features.remove_component_feature` · `component_features.restore_component_feature` · `component_features.update_component_feature` · `component_price_points.archive_component_price_point` · `component_price_points.bulk_create_component_price_points` · `component_price_points.clone_component_price_point` · `component_price_points.list_all_component_price_points` · `components.archive_component` · `components.create_event_based_component` · `components.create_metered_component` · `components.create_on_off_component` · `components.create_prepaid_usage_component` · `components.create_quantity_based_component` · `components.update_component` · `components.update_product_family_component` · `coupons.create_coupon` · `coupons.update_coupon` · `entitlements.read_subscription_entitlements` · `feature_templates.archive_feature_template` · `feature_templates.create_feature_template` · `feature_templates.list_feature_templates` · `feature_templates.read_feature_template` · `feature_templates.restore_feature_template` · `feature_templates.update_feature_template` · `invoices.delete_invoice` · `invoices.issue_invoice` · `invoices.preview_customer_information_changes` · `invoices.record_payment_for_invoice` · `invoices.record_payment_for_multiple_invoices` · `invoices.record_payment_for_subscription` · `invoices.refund_invoice` · `invoices.reopen_invoice` · `invoices.send_invoice` · `invoices.update_customer_information` · `invoices.update_invoice` · `invoices.void_invoice` · `offers.list_offers` · `payment_profiles.change_subscription_default_payment_profile` · `payment_profiles.change_subscription_group_default_payment_profile` · `payment_profiles.create_payment_profile` · `payment_profiles.delete_unused_payment_profile` · `payment_profiles.read_one_time_token` · `payment_profiles.send_request_update_payment_email` · `payment_profiles.verify_bank_account` · `product_families.create_product_family` · `product_features.create_product_feature` · `product_features.list_product_features` · `product_features.read_product_feature` · `product_features.remove_product_feature` · `product_features.restore_product_feature` · `product_features.update_product_feature` · `product_price_points.archive_product_price_point` · `product_price_points.list_all_product_price_points` · `products.archive_product` · `products.create_product` · `products.update_product` · `proforma_invoices.create_consolidated_proforma_invoice` · `proforma_invoices.create_proforma_invoice` · `proforma_invoices.deliver_proforma_invoice` · `proforma_invoices.preview_proforma_invoice` · `proforma_invoices.void_proforma_invoice` · `reason_codes.create_reason_code` · `reason_codes.list_reason_codes` · `reason_codes.update_reason_code` · `subscription_components.allocate_component` · `subscription_components.allocate_components` · `subscription_components.create_usage` · `subscription_components.list_allocations` · `subscription_group_invoice_account.create_subscription_group_prepayment` · `subscription_group_invoice_account.deduct_subscription_group_service_credit` · `subscription_group_invoice_account.issue_subscription_group_service_credit` · `subscription_group_status.cancel_delayed_cancellation_for_group` · `subscription_group_status.cancel_subscriptions_in_group` · `subscription_group_status.initiate_delayed_cancellation_for_group` · `subscription_group_status.reactivate_subscription_group` · `subscription_groups.remove_subscription_from_group` · `subscription_invoice_account.list_service_credits` · `subscription_notes.create_subscription_note` · `subscription_notes.list_subscription_notes` · `subscription_notes.update_subscription_note` · `subscription_products.migrate_subscription_product` · `subscription_products.preview_subscription_product_migration` · `subscription_renewals.cancel_scheduled_renewal_configuration` · `subscription_renewals.create_scheduled_renewal_configuration` · `subscription_renewals.create_scheduled_renewal_configuration_item` · `subscription_renewals.delete_scheduled_renewal_configuration_item` · `subscription_renewals.lock_in_scheduled_renewal_immediately` · `subscription_renewals.schedule_scheduled_renewal_lock_in` · `subscription_renewals.unpublish_scheduled_renewal_configuration` · `subscription_renewals.update_scheduled_renewal_configuration` · `subscription_renewals.update_scheduled_renewal_configuration_item` · `subscription_status.cancel_dunning` · `subscription_status.initiate_delayed_cancellation` · `subscription_status.pause_subscription` · `subscription_status.preview_renewal` · `subscription_status.reactivate_subscription` · `subscription_status.resume_subscription` · `subscription_status.retry_subscription` · `subscription_status.update_automatic_subscription_resumption` · `subscriptions.create_subscription` · `subscriptions.update_subscription` · `webhooks.create_endpoint` · `webhooks.update_endpoint`); distinguishing members `errors` 2. `ErrorArrayMapResponse1` — 12 operations (`component_price_points.create_component_price_point` · `component_price_points.create_currency_prices` · `component_price_points.update_component_price_point` · `component_price_points.update_currency_prices` · `invoices.create_invoice` · `invoices.update_invoice` · `offers.create_offer` · `product_price_points.create_product_currency_prices` · `product_price_points.update_product_currency_prices` · `proforma_invoices.create_signup_proforma_invoice` · `proforma_invoices.preview_signup_proforma_invoice` · `subscriptions.activate_subscription`); distinguishing members *none required* 3. `SingleErrorResponse1` — 8 operations (`api_exports.export_invoices` · `api_exports.export_proforma_invoices` · `api_exports.export_subscriptions` · `custom_fields.create_metadata` · `custom_fields.create_metafields` · `custom_fields.update_metadata` · `custom_fields.update_metafield` · `subscriptions.override_subscription`); distinguishing members `error` 4. `Any | None` — 2 operations (`invoices.reopen_invoice` · `invoices.void_invoice`); distinguishing members *none required* @@ -275,24 +273,25 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 17. `DeductServiceCreditErrorResponse` — 1 operation (`subscription_invoice_account.deduct_service_credit`); distinguishing members *none required* 18. `EventBasedBillingListSegmentsErrors1` — 1 operation (`events_based_billing_segments.list_segments_for_price_point`); distinguishing members *none required* 19. `IssueServiceCreditErrorResponse` — 1 operation (`subscription_invoice_account.issue_service_credit`); distinguishing members *none required* - 20. `MaxioGatewayOauthError` — 1 operation (`maxio_gateway.request_access_token`); distinguishing members `error` - 21. `PrepaidConfigurationErrorResponse` — 1 operation (`subscriptions.update_prepaid_subscription_configuration`); distinguishing members *none required* - 22. `ProductPricePointErrorResponse1` — 1 operation (`product_price_points.create_product_price_point`); distinguishing members `errors` - 23. `RefundPrepaymentBaseErrorsResponse1` — 1 operation (`subscription_invoice_account.refund_prepayment`); distinguishing members *none required* - 24. `RefundPrepaymentErrorResponse` — 1 operation (`subscription_invoice_account.refund_prepayment`); distinguishing members *none required* - 25. `SubscriptionAddCouponError1` — 1 operation (`subscriptions.apply_coupons_to_subscription`); distinguishing members *none required* - 26. `SubscriptionGroupCreateErrorResponse1` — 1 operation (`subscription_groups.create_subscription_group`); distinguishing members `errors` - 27. `SubscriptionGroupSignupErrorResponse1` — 1 operation (`subscription_groups.signup_with_subscription_group`); distinguishing members `errors` - 28. `SubscriptionGroupUpdateErrorResponse1` — 1 operation (`subscription_groups.update_subscription_group_members`); distinguishing members *none required* - 29. `SubscriptionRemoveCouponErrors1` — 1 operation (`subscriptions.remove_coupon_from_subscription`); distinguishing members `subscription` - 30. `SubscriptionResponse` — 1 operation (`subscriptions.purge_subscription`); distinguishing members *none required* - 31. `SubscriptionsMrrErrorResponse1` — 1 operation (`insights.list_mrr_per_subscription`); distinguishing members `errors` - 32. `TooManyManagementLinkRequestsError1` — 1 operation (`billing_portal.read_billing_portal_link`); distinguishing members `errors` - 33. `dict[str, Any]` — 1 operation (`product_price_points.bulk_create_product_price_points`); distinguishing members *none required* - 34. *(none)* — `api_exports.list_exported_invoices` · `api_exports.list_exported_proforma_invoices` · `api_exports.list_exported_subscriptions` · `api_exports.read_invoices_export` · `api_exports.read_proforma_invoices_export` · `api_exports.read_subscriptions_export` · `advance_invoice.read_advance_invoice` · `advance_invoice.void_advance_invoice` · `billing_portal.revoke_billing_portal_access` · `component_price_points.list_component_price_points` · `component_price_points.promote_component_price_point_to_default` · `component_price_points.read_component_price_point` · `component_price_points.unarchive_component_price_point` · `components.find_component` · `components.list_components` · `components.list_components_for_product_family` · `components.read_component` · `coupons.archive_coupon` · `coupons.create_coupon_subcodes` · `coupons.delete_coupon_subcode` · `coupons.find_coupon` · `coupons.list_coupon_subcodes` · `coupons.list_coupons` · `coupons.list_coupons_for_product_family` · `coupons.read_coupon` · `coupons.read_coupon_usage` · `coupons.update_coupon_subcodes` · `custom_fields.delete_metadata` · `custom_fields.delete_metafield` · `custom_fields.list_metadata` · `custom_fields.list_metadata_for_resource_type` · `custom_fields.list_metafields` · `customers.delete_customer` · `customers.list_customer_subscriptions` · `customers.list_customers` · `customers.read_customer` · `customers.read_customer_by_reference` · `events.list_events` · `events.list_subscription_events` · `events.read_events_count` · `events_based_billing_segments.delete_segment` · `insights.list_mrr_movements` · `insights.read_mrr` · `insights.read_site_stats` · `invoices.list_consolidated_invoice_segments` · `invoices.list_credit_notes` · `invoices.list_invoice_events` · `invoices.list_invoices` · `invoices.read_credit_note` · `invoices.read_invoice` · `offers.archive_offer` · `offers.read_offer` · `offers.unarchive_offer` · `payment_profiles.delete_subscription_group_payment_profile` · `payment_profiles.delete_subscriptions_payment_profile` · `payment_profiles.list_payment_profiles` · `payment_profiles.read_payment_profile` · `product_families.list_product_families` · `product_families.read_product_family` · `product_price_points.list_product_price_points` · `product_price_points.promote_product_price_point_to_default` · `product_price_points.read_product_price_point` · `product_price_points.unarchive_product_price_point` · `product_price_points.update_product_price_point` · `products.list_products` · `products.read_product` · `products.read_product_by_handle` · `proforma_invoices.list_proforma_invoices` · `proforma_invoices.list_subscription_group_proforma_invoices` · `proforma_invoices.read_proforma_invoice` · `reason_codes.delete_reason_code` · `reason_codes.read_reason_code` · `sales_commissions.list_sales_commission_settings` · `sales_commissions.list_sales_reps` · `sales_commissions.read_sales_rep` · `sites.clear_site` · `sites.list_chargify_js_public_keys` · `sites.read_site` · `subscription_components.activate_event_based_component` · `subscription_components.bulk_record_events` · `subscription_components.bulk_reset_subscription_components_price_points` · `subscription_components.deactivate_event_based_component` · `subscription_components.list_subscription_components` · `subscription_components.list_subscription_components_for_site` · `subscription_components.list_usages` · `subscription_components.read_subscription_component` · `subscription_components.record_event` · `subscription_group_invoice_account.list_prepayments_for_subscription_group` · `subscription_groups.add_subscription_to_group` · `subscription_groups.delete_subscription_group` · `subscription_groups.find_subscription_group` · `subscription_groups.list_subscription_groups` · `subscription_groups.read_subscription_group` · `subscription_invoice_account.list_prepayments` · `subscription_invoice_account.read_account_balances` · `subscription_notes.delete_subscription_note` · `subscription_notes.read_subscription_note` · `subscription_renewals.list_scheduled_renewal_configurations` · `subscription_renewals.read_scheduled_renewal_configuration` · `subscription_status.cancel_delayed_cancellation` · `subscriptions.find_subscription` · `subscriptions.list_subscriptions` · `subscriptions.preview_subscription` · `subscriptions.read_subscription` · `webhooks.enable_webhooks` · `webhooks.list_endpoints` · `webhooks.list_webhooks` · `webhooks.replay_webhooks`: no typed arm, so `.error` is always `RawError` - 35. So `isinstance(e.error, ErrorListResponse1)` matches only 91 of 250 operations. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 95 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which environment the `environment` keyword selects**, because omitting it is silently `"us"`. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. + 20. `PrepaidConfigurationErrorResponse` — 1 operation (`subscriptions.update_prepaid_subscription_configuration`); distinguishing members *none required* + 21. `ProductPricePointErrorResponse1` — 1 operation (`product_price_points.create_product_price_point`); distinguishing members `errors` + 22. `RefundPrepaymentBaseErrorsResponse1` — 1 operation (`subscription_invoice_account.refund_prepayment`); distinguishing members *none required* + 23. `RefundPrepaymentErrorResponse` — 1 operation (`subscription_invoice_account.refund_prepayment`); distinguishing members *none required* + 24. `SubscriptionAddCouponError1` — 1 operation (`subscriptions.apply_coupons_to_subscription`); distinguishing members *none required* + 25. `SubscriptionGroupCreateErrorResponse1` — 1 operation (`subscription_groups.create_subscription_group`); distinguishing members `errors` + 26. `SubscriptionGroupSignupErrorResponse1` — 1 operation (`subscription_groups.signup_with_subscription_group`); distinguishing members `errors` + 27. `SubscriptionGroupUpdateErrorResponse1` — 1 operation (`subscription_groups.update_subscription_group_members`); distinguishing members *none required* + 28. `SubscriptionRemoveCouponErrors1` — 1 operation (`subscriptions.remove_coupon_from_subscription`); distinguishing members `subscription` + 29. `SubscriptionResponse` — 1 operation (`subscriptions.purge_subscription`); distinguishing members *none required* + 30. `SubscriptionsMrrErrorResponse1` — 1 operation (`insights.list_mrr_per_subscription`); distinguishing members `errors` + 31. `TooManyManagementLinkRequestsError1` — 1 operation (`billing_portal.read_billing_portal_link`); distinguishing members `errors` + 32. `dict[str, Any]` — 1 operation (`product_price_points.bulk_create_product_price_points`); distinguishing members *none required* + 33. *(none)* — `api_exports.list_exported_invoices` · `api_exports.list_exported_proforma_invoices` · `api_exports.list_exported_subscriptions` · `api_exports.read_invoices_export` · `api_exports.read_proforma_invoices_export` · `api_exports.read_subscriptions_export` · `advance_invoice.read_advance_invoice` · `advance_invoice.void_advance_invoice` · `billing_portal.revoke_billing_portal_access` · `component_price_points.list_component_price_points` · `component_price_points.promote_component_price_point_to_default` · `component_price_points.read_component_price_point` · `component_price_points.unarchive_component_price_point` · `components.find_component` · `components.list_components` · `components.list_components_for_product_family` · `components.read_component` · `coupons.archive_coupon` · `coupons.create_coupon_subcodes` · `coupons.delete_coupon_subcode` · `coupons.find_coupon` · `coupons.list_coupon_subcodes` · `coupons.list_coupons` · `coupons.list_coupons_for_product_family` · `coupons.read_coupon` · `coupons.read_coupon_usage` · `coupons.update_coupon_subcodes` · `custom_fields.delete_metadata` · `custom_fields.delete_metafield` · `custom_fields.list_metadata` · `custom_fields.list_metadata_for_resource_type` · `custom_fields.list_metafields` · `customers.delete_customer` · `customers.list_customer_subscriptions` · `customers.list_customers` · `customers.read_customer` · `customers.read_customer_by_reference` · `events.list_events` · `events.list_subscription_events` · `events.read_events_count` · `events_based_billing_segments.delete_segment` · `insights.list_mrr_movements` · `insights.read_mrr` · `insights.read_site_stats` · `invoices.list_consolidated_invoice_segments` · `invoices.list_credit_notes` · `invoices.list_invoice_events` · `invoices.list_invoices` · `invoices.read_credit_note` · `invoices.read_invoice` · `offers.archive_offer` · `offers.read_offer` · `offers.unarchive_offer` · `payment_profiles.delete_subscription_group_payment_profile` · `payment_profiles.delete_subscriptions_payment_profile` · `payment_profiles.list_payment_profiles` · `payment_profiles.read_payment_profile` · `product_families.list_product_families` · `product_families.read_product_family` · `product_price_points.list_product_price_points` · `product_price_points.promote_product_price_point_to_default` · `product_price_points.read_product_price_point` · `product_price_points.unarchive_product_price_point` · `product_price_points.update_product_price_point` · `products.list_products` · `products.read_product` · `products.read_product_by_handle` · `proforma_invoices.list_proforma_invoices` · `proforma_invoices.list_subscription_group_proforma_invoices` · `proforma_invoices.read_proforma_invoice` · `reason_codes.delete_reason_code` · `reason_codes.read_reason_code` · `sales_commissions.list_sales_commission_settings` · `sales_commissions.list_sales_reps` · `sales_commissions.read_sales_rep` · `sites.clear_site` · `sites.list_chargify_js_public_keys` · `sites.read_site` · `subscription_components.activate_event_based_component` · `subscription_components.bulk_record_events` · `subscription_components.bulk_reset_subscription_components_price_points` · `subscription_components.deactivate_event_based_component` · `subscription_components.list_subscription_components` · `subscription_components.list_subscription_components_for_site` · `subscription_components.list_usages` · `subscription_components.read_subscription_component` · `subscription_components.record_event` · `subscription_group_invoice_account.list_prepayments_for_subscription_group` · `subscription_groups.add_subscription_to_group` · `subscription_groups.delete_subscription_group` · `subscription_groups.find_subscription_group` · `subscription_groups.list_subscription_groups` · `subscription_groups.read_subscription_group` · `subscription_invoice_account.list_prepayments` · `subscription_invoice_account.read_account_balances` · `subscription_notes.delete_subscription_note` · `subscription_notes.read_subscription_note` · `subscription_renewals.list_scheduled_renewal_configurations` · `subscription_renewals.read_scheduled_renewal_configuration` · `subscription_status.cancel_delayed_cancellation` · `subscriptions.find_subscription` · `subscriptions.list_subscriptions` · `subscriptions.preview_subscription` · `subscriptions.read_subscription` · `webhooks.enable_webhooks` · `webhooks.list_endpoints` · `webhooks.list_webhooks` · `webhooks.replay_webhooks`: no typed arm, so `.error` is always `RawError` + 34. So `isinstance(e.error, ErrorListResponse1)` matches only 110 of 268 operations. + 35. An arm that is not a class (`Any`, `list[...]`, `dict[...]`) is not an `isinstance` target: narrow the class arms first, then branch on `e.status_code` for what remains. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 110 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which environment the `environment` keyword selects**, because omitting it is silently `"us"`. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/maxio/skills/python/python-integrate-maxio/SKILL.md b/plugins/maxio/skills/python/python-integrate-maxio/SKILL.md index 20f427b2f..226cb4a85 100644 --- a/plugins/maxio/skills/python/python-integrate-maxio/SKILL.md +++ b/plugins/maxio/skills/python/python-integrate-maxio/SKILL.md @@ -1,9 +1,9 @@ --- name: "python-integrate-maxio" -description: "MANDATORY FIRST STEP for Maxio Advanced Billing Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Maxio Advanced Billing in Python — resends a customer's billing portal invitation, revokes a customer's billing portal invitation, creates a price point for an existing component, unarchives a component price point, lists components for a site, lists components for a particular product family, updates a component, creates subcodes for an existing coupon — or when a Maxio Advanced Billing Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." +description: "MANDATORY FIRST STEP for Maxio Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Maxio in Python — resends a customer's billing portal invitation, revokes a customer's billing portal invitation, creates a price point for an existing component, unarchives a component price point, lists components for a site, lists components for a particular product family, updates a component, creates subcodes for an existing coupon — or when a Maxio Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." --- -# Maxio Advanced Billing Python SDK — Integration workflow (lookup layer + contract sheet) +# Maxio Python SDK — Integration workflow (lookup layer + contract sheet) You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. @@ -11,24 +11,24 @@ You do the SDK lookups yourself: `python-getting-started` is your **lookup layer - **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import maxio_advanced_billing, pathlib; print(pathlib.Path(maxio_advanced_billing.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import maxio, pathlib; print(pathlib.Path(maxio.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) - **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. -**Scope guard:** the APIMatic-generated Maxio Advanced Billing **Python SDK** (import root `maxio_advanced_billing`, distribution `maxio-advanced-billing`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. +**Scope guard:** the APIMatic-generated Maxio **Python SDK** (import root `maxio`, distribution `maxio`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. ## Workflow -**If the user opens with a reported SDK error or unexpected Maxio Advanced Billing behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: +**If the user opens with a reported SDK error or unexpected Maxio behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/maxio-advanced-billing-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/maxio-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `maxio-advanced-billing` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; +- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `maxio` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; - a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; - credentials/environment verification (per the task's secret-handling rules) — including which host `environment` will select, since omitting it is silent; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; @@ -36,9 +36,24 @@ Your FIRST action is to load `python-getting-started` and work through the user' Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. -**HARD GATE — no project-file creation or edits until:** `maxio-advanced-billing-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. +**HARD GATE — no project-file creation or edits until:** `maxio-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. -**What the gate does not cover:** creating or activating a virtual environment, installing `maxio-advanced-billing` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. +**What the gate does not cover:** creating or activating a virtual environment, installing `maxio` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. ### Step 1c — Required reading (do this before you write any code) @@ -50,30 +65,31 @@ These are API-agnostic usage skills; loading them is not a substitute for the lo Before implementing, check the plan's **Assumptions & Blockers** section: -- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `maxio-advanced-billing-plan.md` in place. +- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `maxio-plan.md` in place. - Minor assumptions only → proceed. Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. ### Step 2 — Implement from the contract sheet -1. Read `maxio-advanced-billing-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. +1. Read `maxio-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. 2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). 3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. 4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `maxio_advanced_billing.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `maxio.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. ### Step 3 — Answering pure questions -A standalone Maxio Advanced Billing question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. +A standalone Maxio question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. ## Anti-patterns — never do these - **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. -- **Never write a Maxio Advanced Billing/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Don't web-search Maxio Advanced Billing topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Maxio Advanced Billing docs describe the REST API, not this SDK's generated surface, and the two disagree on names. +- **Never write a Maxio/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** +- **Don't web-search Maxio topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Maxio docs describe the REST API, not this SDK's generated surface, and the two disagree on names. - **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. - **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. diff --git a/plugins/maxio/skills/python/python-models/SKILL.md b/plugins/maxio/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/maxio/skills/python/python-models/SKILL.md +++ b/plugins/maxio/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/maxio/skills/python/python-models/reference.md b/plugins/maxio/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/maxio/skills/python/python-models/reference.md +++ b/plugins/maxio/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/maxio/skills/python/python-testing/SKILL.md b/plugins/maxio/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/maxio/skills/python/python-testing/SKILL.md +++ b/plugins/maxio/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/maxio/skills/typescript/typescript-authentication/SKILL.md b/plugins/maxio/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/maxio/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/maxio/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/maxio/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/maxio/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/maxio/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/maxio/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/maxio/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/maxio/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/maxio/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/maxio/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/maxio/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/maxio/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/maxio/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/maxio/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/maxio/skills/typescript/typescript-error-handling/SKILL.md b/plugins/maxio/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/maxio/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/maxio/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/maxio/skills/typescript/typescript-getting-started/SKILL.md b/plugins/maxio/skills/typescript/typescript-getting-started/SKILL.md index 91e1b8dce..b20d07d6b 100644 --- a/plugins/maxio/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/maxio/skills/typescript/typescript-getting-started/SKILL.md @@ -1,11 +1,11 @@ --- name: "typescript-getting-started" -description: "Maxio Advanced Billing TypeScript SDK identity and lookup layer (TypeScript/JavaScript only) — install, the single import specifier `maxio-advanced-billing`, the server environments and the base-URL knob, the auth pattern, the SDK map that ships inside the installed package (`sdk-map.md` + `map/operations/`) and how to traverse it, and the file table naming the one source file owning each fact the map leaves to the source. Load this before answering any Maxio Advanced Billing TypeScript SDK contract question or writing any SDK code." +description: "Maxio TypeScript SDK identity and lookup layer (TypeScript/JavaScript only) — install, the single import specifier `maxio`, the server environments and the base-URL knob, the auth pattern, the SDK map that ships inside the installed package (`sdk-map.md` + `map/operations/`) and how to traverse it, and the file table naming the one source file owning each fact the map leaves to the source. Load this before answering any Maxio TypeScript SDK contract question or writing any SDK code." --- -# Getting started with the Maxio Advanced Billing TypeScript SDK +# Getting started with the Maxio TypeScript SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Maxio Advanced Billing TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-maxio` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Maxio TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-maxio` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated TypeScript SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `typescript-client-initialization`, `typescript-authentication`, `typescript-calling-endpoints`, `typescript-models`, `typescript-error-handling`, `typescript-configuration-resilience` and `typescript-testing`. @@ -13,54 +13,56 @@ This is the **SDK-specific** entry point. For general patterns that apply to any ## SDK identity -Verified against `package.json` and `sdk-map.md` of the generated package at version `1.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. +Verified against `package.json` and `sdk-map.md` of the generated package at version `1.0.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. | Fact | Value | | --- | --- | -| API | Maxio Advanced Billing | -| Package name (what you install, and what you import) | `maxio-advanced-billing` — **not on npm**; built from source (see *Install*) | -| Import specifier | `maxio-advanced-billing` — the package root is the **only** entry; deep imports do not resolve | -| Version | `1.0` (API spec version `1.0`) | -| Client class | `MaxioAdvancedBillingClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new MaxioAdvancedBillingClient(clientOptions: Partial = {})` — **every** field is optional, so `new MaxioAdvancedBillingClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `basicAuth` · `bearerAuth`. `timeout` defaults to `60_000` ms | -| Auth | **HTTP Basic** — set `ClientOptions.basicAuth`
**Bearer token** — set `ClientOptions.bearerAuth` | -| Environments | 3 environments (`ServerEnvironment.Us` *(default)*, `ServerEnvironment.Eu`, `ServerEnvironment.MaxioApiGateway`) × 3 server groups | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`) | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| API | Maxio | +| Package name (what you install, and what you import) | `maxio` — **not on npm**; built from source (see *Install*) | +| Import specifier | `maxio` — the package root is the **only** entry; deep imports do not resolve | +| Source repository | https://github.com/context-plugins/maxio-typescript-sdk (branch `main` — the ref this map documents) | +| Version | `1.0.0` (API spec version `1.0`) | +| Client class | `MaxioClient` (`src/client.ts`) — one class, no sync/async split | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new MaxioClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new MaxioClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `retry` · `fetch` · `basicAuth`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | +| Auth | **HTTP Basic** — set `ClientOptions.basicAuth` | +| Environments | 2 environments (`ServerEnvironment.Us` *(default)*, `ServerEnvironment.Eu`) × 2 server groups | +| Base-URL config | `serverOptions..baseUrl` (`src/servers.ts`) | +| Log environment variable | `MAXIO_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | -| Surface | 250 operations · 34 resources · 563 models · 98 open enums · 90 unions · 166 per-operation error subclasses | +| Surface | 268 operations · 37 resources · 576 models · 111 open enums · 97 unions · 184 per-operation error subclasses | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, import specifier, the auth *pattern*, the base-URL knob), while the actual integration code comes from the companion skills. Load each one as you reach its step (see **Integration workflow** below) and confirm its types against the installed package. ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/maxio-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 751 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 784 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { MaxioAdvancedBillingClient, ServerEnvironment, ResponseError, MaxioAdvancedBillingError } from "maxio-advanced-billing"; -import type { ClientOptions, AchAgreement } from "maxio-advanced-billing"; +import { MaxioClient, ServerEnvironment, ApiError, MaxioError } from "maxio"; +import type { ClientOptions, AchAgreement } from "maxio"; ``` Things the specifier alone will not tell you: -- **Deep imports do not resolve.** The `exports` map exposes `.` and `./package.json` and nothing else, so `maxio-advanced-billing/models/…` fails (`TS2307`) even though the file exists in the shipped `src/`. Every `Source` path on the SDK map is where to **read** a shape, never what to import. -- **⚠ The SDK exports a model type literally named `Event`** (`src/models/event.ts`, schema `eventSchema`). Import it unaliased and it **shadows the global `Event`** for the rest of the file. Alias it: `import type { Event as SdkEvent } from "maxio-advanced-billing"`. -- **From CommonJS**, the typed spelling is `import sdk = require("maxio-advanced-billing")`. A plain `require` destructure runs but yields `any`. +- **Deep imports do not resolve.** The `exports` map exposes `.` and `./package.json` and nothing else, so `maxio/models/…` fails (`TS2307`) even though the file exists in the shipped `src/`. Every `Source` path on the SDK map is where to **read** a shape, never what to import. +- **⚠ The SDK exports a model type literally named `Event`** (`src/models/event.ts`, schema `eventSchema`). Import it unaliased and it **shadows the global `Event`** for the rest of the file. Alias it: `import type { Event as SdkEvent } from "maxio"`. +- **From CommonJS**, the typed spelling is `import sdk = require("maxio")`. A plain `require` destructure runs but yields `any`. - **`instanceof` is reliable within one dialect.** A process that loads both (`import` in one file, `require` in another) gets two independent copies of every error class, and `instanceof` across that boundary is `false` — narrow on `err.kind` / `err.payload.kind` / `err.name` there. Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, every model type) must be imported with `import type`. Under `exactOptionalPropertyTypes`, **omit or spread** an absent optional field rather than assigning `undefined` to it. @@ -71,36 +73,29 @@ Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, | Group | Environment | Base URL | Override at | | --- | --- | --- | --- | -| `production` | `us` *(default)* | `https://{site}.chargify.com` | `serverOptions.production.us.baseUrl` | -| `production` | `eu` | `https://{site}.ebilling.maxio.com` | `serverOptions.production.eu.baseUrl` | -| `production` | `maxioApiGateway` | `https://{connector}.api.maxio.com/api/v1/billing` | `serverOptions.production.maxioApiGateway.baseUrl` | -| `ebb` | `us` *(default)* | `https://events.chargify.com/{site}` | `serverOptions.ebb.us.baseUrl` | -| `ebb` | `eu` | `https://events.chargify.com/{site}` | `serverOptions.ebb.eu.baseUrl` | -| `ebb` | `maxioApiGateway` | `https://events.chargify.com/{site}` | `serverOptions.ebb.maxioApiGateway.baseUrl` | -| `oauth` | `us` *(default)* | `https://{connector}.api.maxio.com` | `serverOptions.oauth.us.baseUrl` | -| `oauth` | `eu` | `https://{connector}.api.maxio.com` | `serverOptions.oauth.eu.baseUrl` | -| `oauth` | `maxioApiGateway` | `https://{connector}.api.maxio.com` | `serverOptions.oauth.maxioApiGateway.baseUrl` | +| `production` | `us` *(default)* | `https://{site}.chargify.com` | `serverOptions.production.baseUrl` | +| `production` | `eu` | `https://{site}.ebilling.maxio.com` | `serverOptions.production.baseUrl` | +| `ebb` | `us` *(default)* | `https://events.chargify.com/{site}` | `serverOptions.ebb.baseUrl` | +| `ebb` | `eu` | `https://events.chargify.com/{site}` | `serverOptions.ebb.baseUrl` | Consequences to state on every contract sheet that touches configuration: - Constructing the client with no options selects **`ServerEnvironment.Us`**, silently. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `production`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. +- An environment value the SDK does not know throws `ConfigurationError` **from the constructor** — every server group is resolved once, as the client is built — so no operation method throws synchronously. -## Auth pattern (2 schemes) +## Auth pattern (1 scheme) -Authentication is **per operation**: every operation declares the requirement it enforces and the SDK sends exactly that. Each block on a map page carries an **Auth** bullet, `none` included. There is no client-global switch and no per-call override. 249 of the 250 operations require a credential and 1 is public. +Authentication is **per operation**: every operation declares the requirement it enforces and the SDK sends exactly that. Each block on a map page carries an **Auth** bullet, `none` included. There is no client-global switch and no per-call override. 268 of the 268 operations require a credential and 0 are public. | `ClientOptions` field | Scheme kind | What the SDK sends | | --- | --- | --- | | `basicAuth` | HTTP Basic | `Authorization: Basic ` | -| `bearerAuth` | Bearer token | `Authorization: Bearer ` | ```ts -const client = new MaxioAdvancedBillingClient({ +const client = new MaxioClient({ basicAuth: { username: process.env.API_USERNAME!, password: process.env.API_PASSWORD! }, - bearerAuth: process.env.API_TOKEN!, }); ``` @@ -110,7 +105,7 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. See `typescript-authentication` for the full picture. @@ -120,22 +115,25 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | Attribute | Class | Ops | Operations | | --- | --- | --- | --- | -| `client.maxioGateway` | `MaxioGateway` | 1 | `requestAccessToken` | | `client.apiExports` | `ApiExports` | 9 | `exportInvoices` · `exportProformaInvoices` · `exportSubscriptions` · `listExportedInvoices` · `listExportedProformaInvoices` · `listExportedSubscriptions` · `readInvoicesExport` · `readProformaInvoicesExport` · `readSubscriptionsExport` | | `client.advanceInvoice` | `AdvanceInvoice` | 3 | `issueAdvanceInvoice` · `readAdvanceInvoice` · `voidAdvanceInvoice` | | `client.billingPortal` | `BillingPortal` | 4 | `enableBillingPortalForCustomer` · `readBillingPortalLink` · `resendBillingPortalInvitation` · `revokeBillingPortalAccess` | | `client.coupons` | `Coupons` | 14 | `archiveCoupon` · `createCoupon` · `createCouponSubcodes` · `createOrUpdateCouponCurrencyPrices` · `deleteCouponSubcode` · `findCoupon` · `listCouponSubcodes` · `listCoupons` · `listCouponsForProductFamily` · `readCoupon` · `readCouponUsage` · `updateCoupon` · `updateCouponSubcodes` · `validateCoupon` | +| `client.componentFeatures` | `ComponentFeatures` | 6 | `createComponentFeature` · `listComponentFeatures` · `readComponentFeature` · `removeComponentFeature` · `restoreComponentFeature` · `updateComponentFeature` | | `client.components` | `Components` | 12 | `archiveComponent` · `createEventBasedComponent` · `createMeteredComponent` · `createOnOffComponent` · `createPrepaidUsageComponent` · `createQuantityBasedComponent` · `findComponent` · `listComponents` · `listComponentsForProductFamily` · `readComponent` · `updateComponent` · `updateProductFamilyComponent` | | `client.componentPricePoints` | `ComponentPricePoints` | 12 | `archiveComponentPricePoint` · `bulkCreateComponentPricePoints` · `cloneComponentPricePoint` · `createComponentPricePoint` · `createCurrencyPrices` · `listAllComponentPricePoints` · `listComponentPricePoints` · `promoteComponentPricePointToDefault` · `readComponentPricePoint` · `unarchiveComponentPricePoint` · `updateComponentPricePoint` · `updateCurrencyPrices` | | `client.customers` | `Customers` | 7 | `createCustomer` · `deleteCustomer` · `listCustomerSubscriptions` · `listCustomers` · `readCustomer` · `readCustomerByReference` · `updateCustomer` | | `client.customFields` | `CustomFields` | 9 | `createMetadata` · `createMetafields` · `deleteMetadata` · `deleteMetafield` · `listMetadata` · `listMetadataForResourceType` · `listMetafields` · `updateMetadata` · `updateMetafield` | +| `client.entitlements` | `Entitlements` | 1 | `readSubscriptionEntitlements` | | `client.events` | `Events` | 3 | `listEvents` · `listSubscriptionEvents` · `readEventsCount` | | `client.eventsBasedBillingSegments` | `EventsBasedBillingSegments` | 6 | `bulkCreateSegments` · `bulkUpdateSegments` · `createSegment` · `deleteSegment` · `listSegmentsForPricePoint` · `updateSegment` | +| `client.featureTemplates` | `FeatureTemplates` | 6 | `archiveFeatureTemplate` · `createFeatureTemplate` · `listFeatureTemplates` · `readFeatureTemplate` · `restoreFeatureTemplate` · `updateFeatureTemplate` | | `client.insights` | `Insights` | 4 | `listMrrMovements` · `listMrrPerSubscription` · `readMrr` · `readSiteStats` | | `client.invoices` | `Invoices` | 19 | `createInvoice` · `deleteInvoice` · `issueInvoice` · `listConsolidatedInvoiceSegments` · `listCreditNotes` · `listInvoiceEvents` · `listInvoices` · `previewCustomerInformationChanges` · `readCreditNote` · `readInvoice` · `recordPaymentForInvoice` · `recordPaymentForMultipleInvoices` · `recordPaymentForSubscription` · `refundInvoice` · `reopenInvoice` · `sendInvoice` · `updateCustomerInformation` · `updateInvoice` · `voidInvoice` | | `client.offers` | `Offers` | 5 | `archiveOffer` · `createOffer` · `listOffers` · `readOffer` · `unarchiveOffer` | | `client.paymentProfiles` | `PaymentProfiles` | 12 | `changeSubscriptionDefaultPaymentProfile` · `changeSubscriptionGroupDefaultPaymentProfile` · `createPaymentProfile` · `deleteSubscriptionGroupPaymentProfile` · `deleteSubscriptionsPaymentProfile` · `deleteUnusedPaymentProfile` · `listPaymentProfiles` · `readOneTimeToken` · `readPaymentProfile` · `sendRequestUpdatePaymentEmail` · `updatePaymentProfile` · `verifyBankAccount` | | `client.productFamilies` | `ProductFamilies` | 4 | `createProductFamily` · `listProductFamilies` · `listProductsForProductFamily` · `readProductFamily` | +| `client.productFeatures` | `ProductFeatures` | 6 | `createProductFeature` · `listProductFeatures` · `readProductFeature` · `removeProductFeature` · `restoreProductFeature` · `updateProductFeature` | | `client.products` | `Products` | 6 | `archiveProduct` · `createProduct` · `listProducts` · `readProduct` · `readProductByHandle` · `updateProduct` | | `client.productPricePoints` | `ProductPricePoints` | 11 | `archiveProductPricePoint` · `bulkCreateProductPricePoints` · `createProductCurrencyPrices` · `createProductPricePoint` · `listAllProductPricePoints` · `listProductPricePoints` · `promoteProductPricePointToDefault` · `readProductPricePoint` · `unarchiveProductPricePoint` · `updateProductCurrencyPrices` · `updateProductPricePoint` | | `client.proformaInvoices` | `ProformaInvoices` | 10 | `createConsolidatedProformaInvoice` · `createProformaInvoice` · `createSignupProformaInvoice` · `deliverProformaInvoice` · `listProformaInvoices` · `listSubscriptionGroupProformaInvoices` · `previewProformaInvoice` · `previewSignupProformaInvoice` · `readProformaInvoice` · `voidProformaInvoice` | @@ -155,28 +153,28 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | `client.subscriptionStatus` | `SubscriptionStatus` | 10 | `cancelDelayedCancellation` · `cancelDunning` · `cancelSubscription` · `initiateDelayedCancellation` · `pauseSubscription` · `previewRenewal` · `reactivateSubscription` · `resumeSubscription` · `retrySubscription` · `updateAutomaticSubscriptionResumption` | | `client.webhooks` | `Webhooks` | 6 | `createEndpoint` · `enableWebhooks` · `listEndpoints` · `listWebhooks` · `replayWebhooks` · `updateEndpoint` | -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. -⚠ **The request type name is not uniformly `Request`.** 47 operations take `RequestParams` instead: `issueAdvanceInvoice`, `updateComponent`, `cloneComponentPricePoint`, `createComponentPricePoint`, `createCurrencyPrices`, `updateComponentPricePoint`, `updateCurrencyPrices`, `createCustomer`, `updateCustomer`, `createMetadata`, `createMetafields`, `updateMetadata`, `createSegment`, `updateSegment`, `createInvoice`, `issueInvoice`, `refundInvoice`, `sendInvoice`, `updateInvoice`, `voidInvoice`, `createOffer`, `createPaymentProfile`, `updatePaymentProfile`, `createProductFamily`, `bulkCreateProductPricePoints`, `createProductCurrencyPrices`, `createProductPricePoint`, `updateProductPricePoint`, `deliverProformaInvoice`, `createReasonCode`, `updateReasonCode`, `activateSubscription`, `createSubscription`, `overrideSubscription`, `updateSubscription`, `createUsage`, `previewAllocations`, `createSubscriptionGroup`, `reactivateSubscriptionGroup`, `createPrepayment`, `deductServiceCredit`, `issueServiceCredit`, `refundPrepayment`, `updateSubscriptionNote`, `reactivateSubscription`, `enableWebhooks`, `replayWebhooks`. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. +⚠ **The request type name is not uniformly `Request`.** 49 operations take `RequestParams` instead: `issueAdvanceInvoice`, `updateComponent`, `cloneComponentPricePoint`, `createComponentPricePoint`, `createCurrencyPrices`, `updateComponentPricePoint`, `updateCurrencyPrices`, `createCustomer`, `updateCustomer`, `createMetadata`, `createMetafields`, `updateMetadata`, `createSegment`, `updateSegment`, `createFeatureTemplate`, `updateFeatureTemplate`, `createInvoice`, `issueInvoice`, `refundInvoice`, `sendInvoice`, `updateInvoice`, `voidInvoice`, `createOffer`, `createPaymentProfile`, `updatePaymentProfile`, `createProductFamily`, `bulkCreateProductPricePoints`, `createProductCurrencyPrices`, `createProductPricePoint`, `updateProductPricePoint`, `deliverProformaInvoice`, `createReasonCode`, `updateReasonCode`, `activateSubscription`, `createSubscription`, `overrideSubscription`, `updateSubscription`, `createUsage`, `previewAllocations`, `createSubscriptionGroup`, `reactivateSubscriptionGroup`, `createPrepayment`, `deductServiceCredit`, `issueServiceCredit`, `refundPrepayment`, `updateSubscriptionNote`, `reactivateSubscription`, `enableWebhooks`, `replayWebhooks`. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. ## SDK map — look up first, open the file second The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: ```bash -node -e "console.log(require.resolve('maxio-advanced-billing/package.json'))" +node -e "console.log(require.resolve('maxio/package.json'))" ``` -Failing that it is at `node_modules/maxio-advanced-billing/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/maxio/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. -Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/maxio-advanced-billing/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. +Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/maxio/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -184,9 +182,9 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. -The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/maxio-advanced-billing/`: +The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/maxio/`: | Question | File | | --- | --- | @@ -194,13 +192,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -214,30 +214,38 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `MAXIO_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new MaxioAdvancedBillingClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) -2. **Authentication** — load **typescript-authentication** before you set credentials. The 2 schemes are `basicAuth`, `bearerAuth` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) -3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **29 operations resolve to `undefined`**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new MaxioClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) +2. **Authentication** — load **typescript-authentication** before you set credentials. The scheme is `basicAuth` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) +3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **32 operations resolve to `undefined`**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `MaxioAdvancedBillingError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `MaxioError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `MAXIO_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK -Beyond the usual signatures and model members, a contract sheet for the Maxio Advanced Billing TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. +Beyond the usual signatures and model members, a contract sheet for the Maxio TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. -1. **Which host each deployment talks to**, and where that is set. The members are `ServerEnvironment.Us`, `ServerEnvironment.Eu`, `ServerEnvironment.MaxioApiGateway`, defaulting to `ServerEnvironment.Us` when the field is unset. -2. **29 operations resolve to `undefined`** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. -3. **The exact request type name per operation**, taken from the **Signature** bullet — 47 operations take `RequestParams`, not `Request`. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +1. **Which host each deployment talks to**, and where that is set. The members are `ServerEnvironment.Us`, `ServerEnvironment.Eu`, defaulting to `ServerEnvironment.Us` when the field is unset. +2. **32 operations resolve to `undefined`** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. +3. **The exact request type name per operation**, taken from the **Signature** bullet — 49 operations take `RequestParams`, not `Request`. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 166 of 250 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. **That `Event` imported from this package is a model type, not the global of that name** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `MaxioAdvancedBillingError` for the same reason. -10. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 184 of 268 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. **That `Event` imported from this package is a model type, not the global of that name** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `MaxioError` for the same reason. +12. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/maxio/skills/typescript/typescript-integrate-maxio/SKILL.md b/plugins/maxio/skills/typescript/typescript-integrate-maxio/SKILL.md index 02c718636..660f3bcd5 100644 --- a/plugins/maxio/skills/typescript/typescript-integrate-maxio/SKILL.md +++ b/plugins/maxio/skills/typescript/typescript-integrate-maxio/SKILL.md @@ -1,85 +1,161 @@ --- name: "typescript-integrate-maxio" -description: "MANDATORY FIRST STEP for Maxio Advanced Billing TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Maxio Advanced Billing in TypeScript/Node — request access token, export invoices, export proforma invoices, export subscriptions, list exported invoices, list exported proforma invoices, or when the Maxio Advanced Billing TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `maxio-advanced-billing` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (maxio-advanced-billing-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — constructing the client with no options silently selects ServerEnvironment.Us, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." +description: "MANDATORY FIRST STEP for Maxio TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Maxio in TypeScript/Node — export invoices, export proforma invoices, export subscriptions, list exported invoices, list exported proforma invoices, list exported subscriptions, or when the Maxio TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `maxio` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (maxio-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — constructing the client with no options silently selects ServerEnvironment.Us, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." --- -# Maxio Advanced Billing TypeScript SDK — Integration workflow (lookup layer + contract sheet) +# Maxio TypeScript SDK — Integration workflow (lookup layer + contract sheet) You do the SDK lookups yourself: `typescript-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source files its table names) before writing code. The `typescript-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. ## Your lookup layer - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. -- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/maxio-advanced-billing/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('maxio-advanced-billing/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/maxio/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('maxio/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. -**Scope guard:** the APIMatic-generated Maxio Advanced Billing **TypeScript SDK** (package and import specifier `maxio-advanced-billing`, client `MaxioAdvancedBillingClient`) in **TypeScript or JavaScript projects only**. Unrelated API, or any language other than TypeScript/JavaScript — do nothing; this skill does not apply. +**Scope guard:** the APIMatic-generated Maxio **TypeScript SDK** (package and import specifier `maxio`, client `MaxioClient`) in **TypeScript or JavaScript projects only**. Unrelated API, or any language other than TypeScript/JavaScript — do nothing; this skill does not apply. ## Workflow -**If the user opens with a reported SDK error, a compile failure, or unexpected Maxio Advanced Billing behaviour** (not new feature work), skip the plan-first flow: load `typescript-getting-started` and `typescript-error-handling`, look the failing symbol up on the map page or in the file the table names, and fix from what you find. Otherwise, for implementation work: +**If the user opens with a reported SDK error, a compile failure, or unexpected Maxio behaviour** (not new feature work), skip the plan-first flow: load `typescript-getting-started` and `typescript-error-handling`, look the failing symbol up on the map page or in the file the table names, and fix from what you find. Otherwise, for implementation work: ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/maxio-advanced-billing-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/maxio-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, **module format — ESM or CJS, which changes how the SDK is imported and whether `instanceof` on its error classes is trustworthy**, and the runtime: Node, a bundled browser app, or an edge runtime) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- **establish the toolchain before you need it**: the package manager, whether `maxio-advanced-billing` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; +- **establish the toolchain before you need it**: the package manager, whether `maxio` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; - a baseline run of the project's checks (`tsc --noEmit`, the test runner, the linter — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since constructing the client with no options silently selects `ServerEnvironment.Us`, and the other 2 environments are reached only by setting it; +- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since constructing the client with no options silently selects `ServerEnvironment.Us`, and the other environment is reached only by setting it; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your credential may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; - setting up your task tracking. Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. -**HARD GATE — no project-file creation or edits until:** `maxio-advanced-billing-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. +**HARD GATE — no project-file creation or edits until:** `maxio-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. -**What the gate does not cover:** installing `maxio-advanced-billing` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +**What the gate does not cover:** installing `maxio` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. + +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. Before implementing, check the plan's **Assumptions & Blockers** section: -- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `maxio-advanced-billing-plan.md` in place. +- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `maxio-plan.md` in place. - Minor assumptions only → proceed. Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. ### Step 2 — Implement from the contract sheet -1. Read `maxio-advanced-billing-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. +1. Read `maxio-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. 2. **Decide which environment this deployment talks to, explicitly, before the first call.** Constructing the client with no options selects `ServerEnvironment.Us` **silently**, so an unset field is indistinguishable from a deliberate choice of the default. Bind it to configuration and fail startup if the (environment, credential) pair is not the one this deployment intends — a credential pointed at the wrong host is not a defect any call site can correct. 3. **Build the client once and hold it.** Resources are memoized lazy getters on it, so rebuilding one per request throws that state away. There is no `close()` — it owns a `fetch`, not a pool. -4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from the **29 operations that resolve to `undefined`** (every operation whose success declares no body — the map's **Returns** bullet names them). `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. +4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from the **32 operations that resolve to `undefined`** (every operation whose success declares no body — the map's **Returns** bullet names them). `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `MaxioError` family, told apart by `err.kind` — and an unknown `serverEnvironment` throws `ConfigurationError` out of the client constructor, before any call exists. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `MAXIO_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions -A standalone Maxio Advanced Billing question with no code change: look it up in `typescript-getting-started` (and the map page or file its table names), then give the grounded answer, citing where it came from. When several questions arrive batched, answer them in one pass. Never answer from memory, even for "easy" questions. +A standalone Maxio question with no code change: look it up in `typescript-getting-started` (and the map page or file its table names), then give the grounded answer, citing where it came from. When several questions arrive batched, answer them in one pass. Never answer from memory, even for "easy" questions. ## Anti-patterns — never do these - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. -- **Never write any Maxio Advanced Billing or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. -- **Don't web-search Maxio Advanced Billing topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Maxio Advanced Billing docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. +- **Never write any Maxio or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Don't web-search Maxio topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Maxio docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. -- **Don't deep-import from the package** (`maxio-advanced-billing/models/…`). The package root is the only entry and deep specifiers do not resolve; the map's `Source` paths are for reading, not importing. -- **Don't import `Event` unaliased.** This SDK exports a type named after a TypeScript/DOM global, so importing it plain shadows the global for the whole file. Alias on import from `maxio-advanced-billing`. +- **Don't deep-import from the package** (`maxio/models/…`). The package root is the only entry and deep specifiers do not resolve; the map's `Source` paths are for reading, not importing. +- **Don't import `Event` unaliased.** This SDK exports a type named after a TypeScript/DOM global, so importing it plain shadows the global for the whole file. Alias on import from `maxio`. - **Don't vendor the SDK's `src/` into the project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly** to make a lookup possible. Install it properly (see `typescript-getting-started`); a clone-based shortcut breaks every import the moment the clone is deleted, and it does not put the map where the lookups expect it. - **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. diff --git a/plugins/maxio/skills/typescript/typescript-models/SKILL.md b/plugins/maxio/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/maxio/skills/typescript/typescript-models/SKILL.md +++ b/plugins/maxio/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/maxio/skills/typescript/typescript-models/reference.md b/plugins/maxio/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/maxio/skills/typescript/typescript-models/reference.md +++ b/plugins/maxio/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/maxio/skills/typescript/typescript-testing/SKILL.md b/plugins/maxio/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/maxio/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/maxio/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/notion/.claude-plugin/plugin.json b/plugins/notion/.claude-plugin/plugin.json index 77d3c74a6..6949832df 100644 --- a/plugins/notion/.claude-plugin/plugin.json +++ b/plugins/notion/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "notion", "displayName": "Notion API", - "version": "0.1.2", + "version": "0.2.0", "description": "Integrate and consume the APIMatic-generated Notion API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/notion/.cursor-plugin/plugin.json b/plugins/notion/.cursor-plugin/plugin.json index 77d3c74a6..6949832df 100644 --- a/plugins/notion/.cursor-plugin/plugin.json +++ b/plugins/notion/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "notion", "displayName": "Notion API", - "version": "0.1.2", + "version": "0.2.0", "description": "Integrate and consume the APIMatic-generated Notion API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/notion/plugin.json b/plugins/notion/plugin.json index 77d3c74a6..6949832df 100644 --- a/plugins/notion/plugin.json +++ b/plugins/notion/plugin.json @@ -1,7 +1,7 @@ { "name": "notion", "displayName": "Notion API", - "version": "0.1.2", + "version": "0.2.0", "description": "Integrate and consume the APIMatic-generated Notion API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/notion/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/notion/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..877957009 100644 --- a/plugins/notion/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/notion/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,245 +1,71 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Notion API .NET SDK in C# — this API authenticates with a Bearer token, and this skill gives the credential each scheme takes, the NotionApiClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Notion API .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with a Bearer token. APIMatic surfaces each scheme as a **nullable credentials property** on `NotionApiClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) - -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). - -## Basic auth - -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` +| Scheme | Property on `NotionApiClientOptions` | Credential | +| --- | --- | --- | +| Bearer token | `BearerAuth` | `string` | -Sends `Authorization: Basic base64(username:password)`. +**That table is the entire authentication surface of this SDK.** `NotionApi.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. ## Bearer token -Set the configured token property on the options class to your access-token string: - -```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; -``` +**API Provider Notes:** -Sends `Authorization: Bearer ACCESS_TOKEN`. +> Notion API uses bearer token authentication. Obtain an integration token from https://www.notion.so/my-integrations. Pass the token in the Authorization header as "Bearer {token}". -## API key (header, query, or cookie) - -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: - -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` - -## OAuth 2.0 — client credentials (machine-to-machine) +`options.BearerAuth` is a plain `string` — assign the access token itself, with no `Bearer` prefix, which the SDK adds: ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; - -options.{OAuthProperty} = new OAuth2ClientCredentials -{ - ClientId = "...", - ClientSecret = "...", - Scope = "..." // optional -}; +options.BearerAuth = "ACCESS_TOKEN"; ``` -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. - -## OAuth 2.0 — authorization code (3-legged, with PKCE) - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; - -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials -{ - ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE - RedirectUri = "https://app.example.com/callback", - Scope = "...", // optional - State = "...", // optional CSRF token - Pkce = PkceMethod.S256, // default; RFC 7636 - PromptForAuthorizationCode = async (authorizationUrl, ct) => - { - // Open/redirect the browser to authorizationUrl, then return the - // authorization code your redirect endpoint received. - return await GetCodeFromUserAsync(authorizationUrl, ct); - } -}; -``` - -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. - -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: - -| PKCE | token request built | no secret ⇒ | -| --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | - -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. - -## OAuth 2.0 — resource owner password - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; - -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` - -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. - -### Supplying your own token source - -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. - -```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) -``` - -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: - -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). - -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. - -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +Sends `Authorization: Bearer ACCESS_TOKEN`. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `NotionApiClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("NotionApiClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.BearerAuth)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "NotionApiClient:BearerAuth is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"NotionApiClient:BearerAuth is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +Check every value the schemes actually need. For this SDK that is: + +| Value to validate | Feeds `NotionApiClientOptions` property | +| --- | --- | +| `BearerAuth` | `BearerAuth` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddNotionApiClient(options => ...)` callback when registering via DI. `NotionApiClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddNotionApiClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/notion/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/notion/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/notion/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/notion/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/notion/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/notion/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/notion/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/notion/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/notion/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/notion/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/notion/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/notion/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/notion/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/notion/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/notion/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/notion/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException
` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/notion/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/notion/skills/dotnet/dotnet-getting-started/SKILL.md index 780931e7e..abd0bd823 100644 --- a/plugins/notion/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/notion/skills/dotnet/dotnet-getting-started/SKILL.md @@ -11,7 +11,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity @@ -26,7 +26,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any | Auth | **Bearer** token — set `BearerAuth` | | Environments | `ServerEnvironment.Production` *(default)* → `https://api.notion.com/v1` | | Log environment variable | `NOTIONAPICLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-notion-api` makes that binding); the **Integration workflow** below says which governs which step. @@ -44,22 +44,24 @@ Models, enums, union types and the generated error types each sit in their own c ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Notion API, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/NotionApi/Models/`, `src/NotionApi/Errors/` and `src/NotionApi/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/NotionApi/Models/`, enums `src/NotionApi/Models/Enums/`, typed errors `src/NotionApi/Errors/`, request records `src/NotionApi/Requests/`, or `src/NotionApi/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: @@ -69,7 +71,7 @@ Keep lookups cheap — the rules that keep a session's context small: ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/notion-api-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/notion-api-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. **Clone once, up front**, into a fresh timestamped folder under `/notion-api-sdk-src/`, and reuse that folder for the rest of your session: @@ -87,30 +89,31 @@ git clone --filter=blob:none --branch main https://github.com/context-plugins/no # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/notion-api-sdk-src/`, **never** in the project repo, and its path never goes into `notion-api-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `notion-api-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `NotionApiClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/NotionApi/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/NotionApi/Models/` (+ `src/NotionApi/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/NotionApi/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/NotionApi/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/NotionApi/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/NotionApi/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/NotionApi/Servers/`, `src/NotionApi/NotionApiClient.cs`, `src/NotionApi/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Notion API may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything @@ -126,7 +129,7 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/NotionApi/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step @@ -134,9 +137,9 @@ You loaded every companion the sheet named before starting (per `dotnet-integrat 1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new NotionApiClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) 2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `NotionApiClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/notion/skills/dotnet/dotnet-integrate-notion-api/SKILL.md b/plugins/notion/skills/dotnet/dotnet-integrate-notion-api/SKILL.md index 6e3e876d4..16eb7a322 100644 --- a/plugins/notion/skills/dotnet/dotnet-integrate-notion-api/SKILL.md +++ b/plugins/notion/skills/dotnet/dotnet-integrate-notion-api/SKILL.md @@ -11,7 +11,7 @@ You are the Notion API .NET SDK specialist for this task — one agent for every ## Grounding rules -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `notion-api-plan.md` — the plan must stay portable. +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `notion-api-plan.md` never carries its path — the plan must stay portable. - **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. - **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. - **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. @@ -29,7 +29,7 @@ Your FIRST deliverable is `notion-api-plan.md` at `/notion-ap #### How to ground (map-first, one pass) 1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. 3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. 4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. 5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. @@ -38,7 +38,7 @@ Your FIRST deliverable is `notion-api-plan.md` at `/notion-ap These need no SDK knowledge and touch no project file, so do them before or alongside the map work: -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); - the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; - locating the SDK reference and its version in the project; @@ -54,10 +54,23 @@ Never use this prerequisite work as a "head start" on implementation: **creating #### The `notion-api-plan.md` format (keep it tight — tables, not prose) 1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + 3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. 6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. 7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. @@ -67,12 +80,46 @@ Never use this prerequisite work as a "head start" on implementation: **creating | 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | | 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | | 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | | 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | | 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | | 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. @@ -95,14 +142,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. 3. After every change: `dotnet build`; fix non-SDK errors yourself. 4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `NotionApi.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. ### Step 4 — Fixing SDK errors (map-first, in place) A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `NotionApi.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/NotionApi/Models/{Type}.cs`, `src/NotionApi/Models/Enums/{Type}.cs`, `src/NotionApi/Errors/{Type}.cs`, `src/NotionApi/Requests/{Type}.cs`, or `src/NotionApi/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. 3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". 4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). 5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. @@ -120,6 +168,6 @@ A standalone Notion API question with no code change: load `dotnet-getting-start - **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. - **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. - **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search Notion API for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `NotionApi` is what the generated source declares, not what public docs describe. +- **Do not web-search Notion API for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `NotionApi` is what the generated source declares, not what public docs describe. - **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. diff --git a/plugins/notion/skills/dotnet/dotnet-models/SKILL.md b/plugins/notion/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/notion/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/notion/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/notion/skills/dotnet/dotnet-models/reference.md b/plugins/notion/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/notion/skills/dotnet/dotnet-models/reference.md +++ b/plugins/notion/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/notion/skills/dotnet/dotnet-testing/SKILL.md b/plugins/notion/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/notion/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/notion/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/notion/skills/python/python-authentication/SKILL.md b/plugins/notion/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/notion/skills/python/python-authentication/SKILL.md +++ b/plugins/notion/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/notion/skills/python/python-calling-endpoints/SKILL.md b/plugins/notion/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/notion/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/notion/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/notion/skills/python/python-client-initialization/SKILL.md b/plugins/notion/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/notion/skills/python/python-client-initialization/SKILL.md +++ b/plugins/notion/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/notion/skills/python/python-configuration-resilience/SKILL.md b/plugins/notion/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/notion/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/notion/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/notion/skills/python/python-error-handling/SKILL.md b/plugins/notion/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/notion/skills/python/python-error-handling/SKILL.md +++ b/plugins/notion/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/notion/skills/python/python-event-streams/SKILL.md b/plugins/notion/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/notion/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/notion/skills/python/python-file-handling/SKILL.md b/plugins/notion/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/notion/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/notion/skills/python/python-getting-started/SKILL.md b/plugins/notion/skills/python/python-getting-started/SKILL.md index 1fabc7f95..1be8d8fed 100644 --- a/plugins/notion/skills/python/python-getting-started/SKILL.md +++ b/plugins/notion/skills/python/python-getting-started/SKILL.md @@ -25,27 +25,32 @@ Verified against `notion_api/` and `pyproject.toml` of the generated package at | Version | `0.1.0` | | Sync client class | `NotionApiClient` (alias `Client`) | | Async client class | `AsyncNotionApiClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`) · `bearer_auth`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`) · `retry_options` · `bearer_auth`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | **Bearer** token — set `bearer_auth` | | Environments | **no environment enum** — one `base_url` string, defaulting to `https://api.notion.com/v1` | | Base-URL config | `ServerConfig` (`notion_api/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | -| Surface | 5 operations, all directly on the client — no controller groups; the map's single page is `map/operations/client.md` · 8 models · 0 unions · 2 enums · 5 per-operation error unions | +| Surface | 5 operations, all directly on the client — no controller groups; the map's single page is `map/operations/client.md` · 8 models · 0 unions · 6 enums · 5 per-operation error unions | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, import roots, the auth *pattern*, the base-URL knob), while the actual integration code comes from the companion skills. Load each one as you reach its step (see **Integration workflow** below) and confirm its types against the installed package. ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash pip install "notion-api @ git+https://github.com/context-plugins/notion-python-sdk.git@main" +python -c "import notion_api, pathlib; print(pathlib.Path(notion_api.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules @@ -107,15 +112,15 @@ See `python-authentication` for the full picture, including what a *failed token The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `notion_api/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `notion_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `notion_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/notion-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. Every `Source` path on the map is relative to that SDK root, so `notion_api/models/block.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. @@ -135,7 +140,7 @@ Read the one module that owns the fact **inside the installed package**. Locate python -c "import notion_api, pathlib; print(pathlib.Path(notion_api.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/notion_api` on Windows, `.venv/lib/python3.*/site-packages/notion_api` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/notion_api` on Windows, `.venv/lib/python3.*/site-packages/notion_api` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -143,7 +148,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/no | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -153,7 +158,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/no | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | | An operation's error mapper (status → schema) | `errors/_error.py` | @@ -170,13 +175,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The one scheme is `bearer_auth=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`), so an unknown wire value passes through as a plain `str` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -187,10 +192,11 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 3. **Every operation returns a payload** — no operation in this SDK returns `None`, so the `with_raw_response` peer is needed only where the status code itself matters. 4. **Required vs `UNSET`** for every model member the task sets, and the fact that `Optional[T]` here is `T | UnsetType` — **not** `typing.Optional`, so `None` is not a legal value for it. 5. **The `ApiError.error` union** for each operation in scope — there is **one** typed error body in this SDK, so the union is never uniform. Every union is ` | RawError`: - 1. `Error` — 5 operations (`append_block_children` · `delete_block` · `retrieve_block` · `retrieve_block_children` · `update_block`); distinguishing members `status` · `code` · `message` + 1. `Error` — 5 operations (`append_block_children` · `delete_block` · `retrieve_block` · `retrieve_block_children` · `update_block`); distinguishing members `object_` · `status` · `code` · `message` 2. So `isinstance(e.error, Error)` matches only 5 of 5 operations. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 5 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which host the `base_url` selects**, because omitting it is silently the default. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 5 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which host the `base_url` selects**, because omitting it is silently the default. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/notion/skills/python/python-integrate-notion-api/SKILL.md b/plugins/notion/skills/python/python-integrate-notion-api/SKILL.md index c002fecea..24c3f8609 100644 --- a/plugins/notion/skills/python/python-integrate-notion-api/SKILL.md +++ b/plugins/notion/skills/python/python-integrate-notion-api/SKILL.md @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `python-getting-started` is your **lookup layer - **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import notion_api, pathlib; print(pathlib.Path(notion_api.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import notion_api, pathlib; print(pathlib.Path(notion_api.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) - **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. @@ -40,6 +40,21 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** creating or activating a virtual environment, installing `notion-api` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. @@ -62,8 +77,9 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. 4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `notion_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `notion_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. ### Step 3 — Answering pure questions diff --git a/plugins/notion/skills/python/python-models/SKILL.md b/plugins/notion/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/notion/skills/python/python-models/SKILL.md +++ b/plugins/notion/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/notion/skills/python/python-models/reference.md b/plugins/notion/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/notion/skills/python/python-models/reference.md +++ b/plugins/notion/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/notion/skills/python/python-testing/SKILL.md b/plugins/notion/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/notion/skills/python/python-testing/SKILL.md +++ b/plugins/notion/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/notion/skills/typescript/typescript-authentication/SKILL.md b/plugins/notion/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/notion/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/notion/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/notion/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/notion/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/notion/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/notion/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/notion/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/notion/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/notion/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/notion/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/notion/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/notion/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/notion/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/notion/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/notion/skills/typescript/typescript-error-handling/SKILL.md b/plugins/notion/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/notion/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/notion/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/notion/skills/typescript/typescript-getting-started/SKILL.md b/plugins/notion/skills/typescript/typescript-getting-started/SKILL.md index b6cf24ef2..8cfda3de4 100644 --- a/plugins/notion/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/notion/skills/typescript/typescript-getting-started/SKILL.md @@ -13,46 +13,48 @@ This is the **SDK-specific** entry point. For general patterns that apply to any ## SDK identity -Verified against `package.json` and `sdk-map.md` of the generated package at version `2022-06-28`. **Re-verify after a version bump** — this page is a snapshot, not a live read. +Verified against `package.json` and `sdk-map.md` of the generated package at version `0.1.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. | Fact | Value | | --- | --- | | API | Notion API | | Package name (what you install, and what you import) | `notion-api` — **not on npm**; built from source (see *Install*) | | Import specifier | `notion-api` — the package root is the **only** entry; deep imports do not resolve | -| Version | `2022-06-28` (API spec version `2022-06-28`) | +| Source repository | https://github.com/context-plugins/notion-typescript-sdk (branch `main` — the ref this map documents) | +| Version | `0.1.0` (API spec version `2022-06-28`) | | Client class | `NotionApiClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new NotionApiClient(clientOptions: Partial = {})` — **every** field is optional, so `new NotionApiClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `bearerAuth`. `timeout` defaults to `60_000` ms | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new NotionApiClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new NotionApiClient()` compiles. Fields: `serverOptions` · `retry` · `fetch` · `bearerAuth`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **Bearer token** — set `ClientOptions.bearerAuth` | -| Environments | 1 environment (`ServerEnvironment.Production` *(default)*) × 1 server group | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`), defaulting to `https://api.notion.com/v1` | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Environments | no selector — one environment, so none is emitted; 1 server group | +| Base-URL config | `serverOptions.baseUrl` (`src/servers.ts`), defaulting to `https://api.notion.com/v1` | +| Log environment variable | `NOTION_API_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | -| Surface | 5 operations · 0 resources · 8 models · 2 open enums · 5 per-operation error subclasses | +| Surface | 5 operations · 0 resources · 8 models · 6 open enums · 5 per-operation error subclasses | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, import specifier, the auth *pattern*, the base-URL knob), while the actual integration code comes from the companion skills. Load each one as you reach its step (see **Integration workflow** below) and confirm its types against the installed package. ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/notion-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 10 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, 14 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { NotionApiClient, ServerEnvironment, ResponseError, NotionApiError } from "notion-api"; +import { NotionApiClient, ApiError, NotionApiError } from "notion-api"; import type { ClientOptions, Block } from "notion-api"; ``` @@ -65,21 +67,19 @@ Things the specifier alone will not tell you: Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, every model type) must be imported with `import type`. Under `exactOptionalPropertyTypes`, **omit or spread** an absent optional field rather than assigning `undefined` to it. -## Environments +## Hosts -`ClientOptions.serverEnvironment` selects one environment for the whole client (`src/servers.ts`). `ServerEnvironment` is a `const` object with a derived union type — not a TypeScript `enum` — and unlike the model enums it is **closed**, so only its declared members are assignable. +This API declares one environment, so the SDK has **no environment selector** — there is no `ServerEnvironment` type and no field to set (`src/servers.ts`). The host below is the only one the SDK knows, and the override in the last column is the only way to reach another. -| Group | Environment | Base URL | Override at | -| --- | --- | --- | --- | -| `default` | `production` *(default)* | `https://api.notion.com/v1` | `serverOptions.default.production.baseUrl` | +| Group | Base URL | Override at | +| --- | --- | --- | +| `default` | `https://api.notion.com/v1` | `serverOptions.baseUrl` | Consequences to state on every contract sheet that touches configuration: -- Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- ⚠ **This SDK declares exactly one environment**, so reaching any other host is a `serverOptions` override, not an environment value — `serverOptions: { default: { production: { baseUrl: "…" } } }`. Note the key is still `production`: the environment name and the host it points at are now decoupled, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the deployment's intent explicit where the client is built, and verify it. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- ⚠ **This SDK declares exactly one environment**, so it emits no `ServerEnvironment` at all and reaching any other host is a `serverOptions` base-URL override — `serverOptions: { baseUrl: "…" }`. Nothing in the options then records **which** deployment this is, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the intent explicit where the client is built, and verify it. +- An override merges with the built-in default **key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. ## Auth pattern (1 scheme) @@ -101,7 +101,7 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. See `typescript-authentication` for the full picture. @@ -111,7 +111,7 @@ This SDK groups nothing: every operation is a method on `client` itself, so ther 5 operations are methods on the client itself: `client.appendBlockChildren` · `client.deleteBlock` · `client.retrieveBlock` · `client.retrieveBlockChildren` · `client.updateBlock`. -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** A name that collides inside its namespace is promoted to `RequestParams` instead — no operation in this SDK takes that spelling at this version. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -119,7 +119,7 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: @@ -128,11 +128,11 @@ Locate the installed package before you rely on a lookup: node -e "console.log(require.resolve('notion-api/package.json'))" ``` -Failing that it is at `node_modules/notion-api/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/notion-api/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/notion-api/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -140,7 +140,7 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/notion-api/`: @@ -150,13 +150,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -170,30 +172,38 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `NOTION_API_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new NotionApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resolved options and the server resolvers live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new NotionApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resolved options and the server resolvers live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) 2. **Authentication** — load **typescript-authentication** before you set credentials. The scheme is `bearerAuth` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **no operation resolves to `undefined` — every one of them carries a body**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `NotionApiError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `NotionApiError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `NOTION_API_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK Beyond the usual signatures and model members, a contract sheet for the Notion API TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. -1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment, `ServerEnvironment.Production`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. +1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment and emits no `ServerEnvironment`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. 2. **no operation resolves to `undefined` — every one of them carries a body** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 5 of 5 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. **That `Error` imported from this package is a model type, not the global of that name** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `NotionApiError` for the same reason. -10. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 5 of 5 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. **That `Error` imported from this package is a model type, not the global of that name** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `NotionApiError` for the same reason. +12. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/notion/skills/typescript/typescript-integrate-notion-api/SKILL.md b/plugins/notion/skills/typescript/typescript-integrate-notion-api/SKILL.md index 551118660..79c03e258 100644 --- a/plugins/notion/skills/typescript/typescript-integrate-notion-api/SKILL.md +++ b/plugins/notion/skills/typescript/typescript-integrate-notion-api/SKILL.md @@ -1,6 +1,6 @@ --- name: "typescript-integrate-notion-api" -description: "MANDATORY FIRST STEP for Notion API TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Notion API in TypeScript/Node — append block children, delete block, retrieve block, retrieve block children, update block, or when the Notion API TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `notion-api` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (notion-api-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares only ServerEnvironment.Production, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." +description: "MANDATORY FIRST STEP for Notion API TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Notion API in TypeScript/Node — append block children, delete block, retrieve block, retrieve block children, update block, or when the Notion API TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `notion-api` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (notion-api-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares one environment and no ServerEnvironment, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." --- # Notion API TypeScript SDK — Integration workflow (lookup layer + contract sheet) @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/notion-api/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('notion-api/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('notion-api/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. @@ -23,14 +23,14 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/notion-api-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/notion-api-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, **module format — ESM or CJS, which changes how the SDK is imported and whether `instanceof` on its error classes is trustworthy**, and the runtime: Node, a bundled browser app, or an edge runtime) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - **establish the toolchain before you need it**: the package manager, whether `notion-api` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; - a baseline run of the project's checks (`tsc --noEmit`, the test runner, the linter — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment (`ServerEnvironment.Production`), so **any other host is a `serverOptions` base-URL override rather than an environment value**; +- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment, so it emits no `ServerEnvironment` at all and **any other host is a `serverOptions` base-URL override rather than an environment value**; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your credential may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; - setting up your task tracking. @@ -40,11 +40,85 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** installing `notion-api` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. @@ -58,13 +132,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp ### Step 2 — Implement from the contract sheet 1. Read `notion-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment — `ServerEnvironment.Production` — so a different host is a `serverOptions` override under a key still named after `production`: the environment name and the host it points at are decoupled, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. +2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment and emits no selector for it, so a different host is a `serverOptions` base-URL override. Nothing in the options then records **which** deployment this is, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. 3. **Build the client once and hold it.** The client holds the resolved options, the server resolvers and the `fetch` the transport uses, so rebuilding one per request throws that state away. There is no `close()` — it owns a `fetch`, not a pool. 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from an operation whose success carries no body. `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `NotionApiError` family, told apart by `err.kind`. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `NOTION_API_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions @@ -74,7 +150,7 @@ A standalone Notion API question with no code change: look it up in `typescript- - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. - **Never write any Notion API or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. - **Don't web-search Notion API topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Notion API docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. diff --git a/plugins/notion/skills/typescript/typescript-models/SKILL.md b/plugins/notion/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/notion/skills/typescript/typescript-models/SKILL.md +++ b/plugins/notion/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/notion/skills/typescript/typescript-models/reference.md b/plugins/notion/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/notion/skills/typescript/typescript-models/reference.md +++ b/plugins/notion/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/notion/skills/typescript/typescript-testing/SKILL.md b/plugins/notion/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/notion/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/notion/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/nytimes/.claude-plugin/plugin.json b/plugins/nytimes/.claude-plugin/plugin.json index 7a5d9270e..39759e5ab 100644 --- a/plugins/nytimes/.claude-plugin/plugin.json +++ b/plugins/nytimes/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "nytimes", "displayName": "The New York Times APIs", - "version": "0.1.2", + "version": "0.2.0", "description": "Integrate and consume the APIMatic-generated The New York Times APIs SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/nytimes/.cursor-plugin/plugin.json b/plugins/nytimes/.cursor-plugin/plugin.json index 7a5d9270e..39759e5ab 100644 --- a/plugins/nytimes/.cursor-plugin/plugin.json +++ b/plugins/nytimes/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "nytimes", "displayName": "The New York Times APIs", - "version": "0.1.2", + "version": "0.2.0", "description": "Integrate and consume the APIMatic-generated The New York Times APIs SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/nytimes/plugin.json b/plugins/nytimes/plugin.json index 7a5d9270e..39759e5ab 100644 --- a/plugins/nytimes/plugin.json +++ b/plugins/nytimes/plugin.json @@ -1,7 +1,7 @@ { "name": "nytimes", "displayName": "The New York Times APIs", - "version": "0.1.2", + "version": "0.2.0", "description": "Integrate and consume the APIMatic-generated The New York Times APIs SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/nytimes/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/nytimes/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..9c4513485 100644 --- a/plugins/nytimes/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/nytimes/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,245 +1,75 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the The New York Times APIs .NET SDK in C# — this API authenticates with an API key in a query parameter, and this skill gives the credential each scheme takes, the TheNewYorkTimesApIsClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the The New York Times APIs .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with an API key in a query parameter. APIMatic surfaces each scheme as a **nullable credentials property** on `TheNewYorkTimesApIsClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) - -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). - -## Basic auth - -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` - -Sends `Authorization: Basic base64(username:password)`. - -## Bearer token - -Set the configured token property on the options class to your access-token string: - -```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; -``` - -Sends `Authorization: Bearer ACCESS_TOKEN`. - -## API key (header, query, or cookie) - -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: - -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` - -## OAuth 2.0 — client credentials (machine-to-machine) - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; - -options.{OAuthProperty} = new OAuth2ClientCredentials -{ - ClientId = "...", - ClientSecret = "...", - Scope = "..." // optional -}; -``` - -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. - -## OAuth 2.0 — authorization code (3-legged, with PKCE) - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; - -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials -{ - ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE - RedirectUri = "https://app.example.com/callback", - Scope = "...", // optional - State = "...", // optional CSRF token - Pkce = PkceMethod.S256, // default; RFC 7636 - PromptForAuthorizationCode = async (authorizationUrl, ct) => - { - // Open/redirect the browser to authorizationUrl, then return the - // authorization code your redirect endpoint received. - return await GetCodeFromUserAsync(authorizationUrl, ct); - } -}; -``` - -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. - -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: - -| PKCE | token request built | no secret ⇒ | +| Scheme | Property on `TheNewYorkTimesApIsClientOptions` | Credential | | --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | +| API key — `api-key` query parameter | `Apikey` | `string` | -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. +**That table is the entire authentication surface of this SDK.** `TheNewYorkTimesApIs.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. -## OAuth 2.0 — resource owner password +## API key (query parameter) -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; - -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` +**API Provider Notes:** + +> API key issued from https://developer.nytimes.com/my-apps. Not required for the RSS feeds. -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. - -### Supplying your own token source - -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. +The key is appended to every request as the `api-key` query parameter. The parameter name is fixed by the generated scheme — you supply only the value: ```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) +options.Apikey = "API_KEY"; ``` -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: +⚠ **A query-string credential is logged by everything on the path.** Proxies, CDNs, load balancers and the provider's own access logs record full URLs, and this SDK's request logging is no exception. Treat the key as exposed to your infrastructure, and rotate it on that basis. -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). +## Operations that need no credentials -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. - -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +Some operations on this API are public: the generated endpoint uses `NoneAuthScheme` and sends no credential, whatever you set on `TheNewYorkTimesApIsClientOptions`. You still construct the same client — there is no separate unauthenticated client — and the credentials you configured are simply not applied to those calls. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `TheNewYorkTimesApIsClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("TheNewYorkTimesApIsClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.Apikey)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "TheNewYorkTimesApIsClient:Apikey is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"TheNewYorkTimesApIsClient:Apikey is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +Check every value the schemes actually need. For this SDK that is: + +| Value to validate | Feeds `TheNewYorkTimesApIsClientOptions` property | +| --- | --- | +| `Apikey` | `Apikey` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddTheNewYorkTimesApIsClient(options => ...)` callback when registering via DI. `TheNewYorkTimesApIsClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddTheNewYorkTimesApIsClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/nytimes/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/nytimes/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/nytimes/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/nytimes/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/nytimes/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/nytimes/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/nytimes/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/nytimes/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/nytimes/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/nytimes/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/nytimes/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/nytimes/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/nytimes/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/nytimes/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/nytimes/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/nytimes/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/nytimes/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/nytimes/skills/dotnet/dotnet-getting-started/SKILL.md index fb922fccc..2a66efe95 100644 --- a/plugins/nytimes/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/nytimes/skills/dotnet/dotnet-getting-started/SKILL.md @@ -11,7 +11,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity @@ -26,7 +26,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any | Auth | **API key** in the `api-key` query parameter — set `Apikey` | | Environments | `ServerEnvironment.Production` *(default)* → `https://api.nytimes.com` | | Log environment variable | `THENEWYORKTIMESAPISCLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-the-new-york-times-apis` makes that binding); the **Integration workflow** below says which governs which step. @@ -44,22 +44,24 @@ Models, enums, union types and the generated error types each sit in their own c ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call The New York Times APIs, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/TheNewYorkTimesApIs/Models/`, `src/TheNewYorkTimesApIs/Errors/` and `src/TheNewYorkTimesApIs/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/TheNewYorkTimesApIs/Models/`, enums `src/TheNewYorkTimesApIs/Models/Enums/`, typed errors `src/TheNewYorkTimesApIs/Errors/`, request records `src/TheNewYorkTimesApIs/Requests/`, or `src/TheNewYorkTimesApIs/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: @@ -69,7 +71,7 @@ Keep lookups cheap — the rules that keep a session's context small: ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/the-new-york-times-apis-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/the-new-york-times-apis-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. **Clone once, up front**, into a fresh timestamped folder under `/the-new-york-times-apis-sdk-src/`, and reuse that folder for the rest of your session: @@ -87,30 +89,31 @@ git clone --filter=blob:none --branch main https://github.com/context-plugins/ny # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/the-new-york-times-apis-sdk-src/`, **never** in the project repo, and its path never goes into `the-new-york-times-apis-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `the-new-york-times-apis-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `TheNewYorkTimesApIsClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/TheNewYorkTimesApIs/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/TheNewYorkTimesApIs/Models/` (+ `src/TheNewYorkTimesApIs/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/TheNewYorkTimesApIs/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/TheNewYorkTimesApIs/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/TheNewYorkTimesApIs/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/TheNewYorkTimesApIs/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/TheNewYorkTimesApIs/Servers/`, `src/TheNewYorkTimesApIs/TheNewYorkTimesApIsClient.cs`, `src/TheNewYorkTimesApIs/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and The New York Times APIs may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything @@ -126,7 +129,7 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/TheNewYorkTimesApIs/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step @@ -134,9 +137,9 @@ You loaded every companion the sheet named before starting (per `dotnet-integrat 1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new TheNewYorkTimesApIsClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) 2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `TheNewYorkTimesApIsClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/nytimes/skills/dotnet/dotnet-integrate-the-new-york-times-apis/SKILL.md b/plugins/nytimes/skills/dotnet/dotnet-integrate-the-new-york-times-apis/SKILL.md index 4f4af06a8..b86528337 100644 --- a/plugins/nytimes/skills/dotnet/dotnet-integrate-the-new-york-times-apis/SKILL.md +++ b/plugins/nytimes/skills/dotnet/dotnet-integrate-the-new-york-times-apis/SKILL.md @@ -11,7 +11,7 @@ You are the The New York Times APIs .NET SDK specialist for this task — one ag ## Grounding rules -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `the-new-york-times-apis-plan.md` — the plan must stay portable. +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `the-new-york-times-apis-plan.md` never carries its path — the plan must stay portable. - **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. - **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. - **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. @@ -29,7 +29,7 @@ Your FIRST deliverable is `the-new-york-times-apis-plan.md` at `` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. 3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. 4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. 5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. @@ -38,7 +38,7 @@ Your FIRST deliverable is `the-new-york-times-apis-plan.md` at ``** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. 6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. 7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. @@ -67,12 +80,46 @@ Never use this prerequisite work as a "head start" on implementation: **creating | 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | | 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | | 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | | 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | | 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | | 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. @@ -95,14 +142,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. 3. After every change: `dotnet build`; fix non-SDK errors yourself. 4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `TheNewYorkTimesApIs.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. ### Step 4 — Fixing SDK errors (map-first, in place) A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `TheNewYorkTimesApIs.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/TheNewYorkTimesApIs/Models/{Type}.cs`, `src/TheNewYorkTimesApIs/Models/Enums/{Type}.cs`, `src/TheNewYorkTimesApIs/Errors/{Type}.cs`, `src/TheNewYorkTimesApIs/Requests/{Type}.cs`, or `src/TheNewYorkTimesApIs/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. 3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". 4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). 5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. @@ -120,6 +168,6 @@ A standalone The New York Times APIs question with no code change: load `dotnet- - **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. - **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. - **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search The New York Times APIs for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `TheNewYorkTimesApIs` is what the generated source declares, not what public docs describe. +- **Do not web-search The New York Times APIs for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `TheNewYorkTimesApIs` is what the generated source declares, not what public docs describe. - **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. diff --git a/plugins/nytimes/skills/dotnet/dotnet-models/SKILL.md b/plugins/nytimes/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/nytimes/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/nytimes/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/nytimes/skills/dotnet/dotnet-models/reference.md b/plugins/nytimes/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/nytimes/skills/dotnet/dotnet-models/reference.md +++ b/plugins/nytimes/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/nytimes/skills/dotnet/dotnet-testing/SKILL.md b/plugins/nytimes/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/nytimes/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/nytimes/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/nytimes/skills/python/python-authentication/SKILL.md b/plugins/nytimes/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/nytimes/skills/python/python-authentication/SKILL.md +++ b/plugins/nytimes/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/nytimes/skills/python/python-calling-endpoints/SKILL.md b/plugins/nytimes/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/nytimes/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/nytimes/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/nytimes/skills/python/python-client-initialization/SKILL.md b/plugins/nytimes/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/nytimes/skills/python/python-client-initialization/SKILL.md +++ b/plugins/nytimes/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/nytimes/skills/python/python-configuration-resilience/SKILL.md b/plugins/nytimes/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/nytimes/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/nytimes/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/nytimes/skills/python/python-error-handling/SKILL.md b/plugins/nytimes/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/nytimes/skills/python/python-error-handling/SKILL.md +++ b/plugins/nytimes/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/nytimes/skills/python/python-event-streams/SKILL.md b/plugins/nytimes/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/nytimes/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/nytimes/skills/python/python-file-handling/SKILL.md b/plugins/nytimes/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/nytimes/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/nytimes/skills/python/python-getting-started/SKILL.md b/plugins/nytimes/skills/python/python-getting-started/SKILL.md index b81374f78..2ae9c57c2 100644 --- a/plugins/nytimes/skills/python/python-getting-started/SKILL.md +++ b/plugins/nytimes/skills/python/python-getting-started/SKILL.md @@ -25,12 +25,12 @@ Verified against `the_new_york_times_apis/` and `pyproject.toml` of the generate | Version | `1.0.0` | | Sync client class | `TheNewYorkTimesApisClient` (alias `Client`) | | Async client class | `AsyncTheNewYorkTimesApisClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`) · `apikey`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`) · `retry_options` · `apikey`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | **API key** in the `api-key` query parameter — set `apikey` | | Environments | **no environment enum** — one `base_url` string, defaulting to `https://api.nytimes.com` | | Base-URL config | `ServerConfig` (`the_new_york_times_apis/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | | Surface | 10 operations across 6 controllers · 45 models · 0 unions · 5 enums · 9 per-operation error unions | @@ -39,13 +39,18 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash pip install "the-new-york-times-apis @ git+https://github.com/context-plugins/nytimes-python-sdk.git@main" +python -c "import the_new_york_times_apis, pathlib; print(pathlib.Path(the_new_york_times_apis.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules @@ -124,15 +129,15 @@ The SDK map's controller table carries the same counts and links to a page per c The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `the_new_york_times_apis/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `the_new_york_times_apis/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `the_new_york_times_apis/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/nytimes-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. Every `Source` path on the map is relative to that SDK root, so `the_new_york_times_apis/models/article.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. @@ -152,7 +157,7 @@ Read the one module that owns the fact **inside the installed package**. Locate python -c "import the_new_york_times_apis, pathlib; print(pathlib.Path(the_new_york_times_apis.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/the_new_york_times_apis` on Windows, `.venv/lib/python3.*/site-packages/the_new_york_times_apis` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/the_new_york_times_apis` on Windows, `.venv/lib/python3.*/site-packages/the_new_york_times_apis` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -160,7 +165,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/th | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -170,7 +175,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/th | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | | An operation's error mapper (status → schema) | `errors/_error.py` | @@ -187,13 +192,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The one scheme is `apikey=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; **1 operation returns `None`**, so `with_raw_response` is the only way to observe its status code; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrInt`/`…OrStr`), so an unknown wire value passes through as a plain `int`/`str` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -207,8 +212,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 1. `SvcArchiveV1Json401Error1` — 1 operation (`archive.get_archive_by_month`); distinguishing members *none required* 2. *(none)* — `books.get_lists_date_list_json` · `books.get_lists_overview_json` · `most_popular.get_most_emailed_articles` · `most_popular.get_most_shared_articles` · `most_popular.get_most_shared_articles_by_type` · `most_popular.get_most_viewed_articles` · `rss.get_section_feed` · `search.search_articles` · `stories.get_top_stories_by_section`: no typed arm, so `.error` is always `RawError` 3. So `isinstance(e.error, SvcArchiveV1Json401Error1)` matches only 1 of 10 operations. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares no required member on any return type, so a truncated body decodes without complaint and the hole surfaces later.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which host the `base_url` selects**, because omitting it is silently the default. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares no required member on any return type, so a truncated body decodes without complaint and the hole surfaces later.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which host the `base_url` selects**, because omitting it is silently the default. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/nytimes/skills/python/python-integrate-the-new-york-times-apis/SKILL.md b/plugins/nytimes/skills/python/python-integrate-the-new-york-times-apis/SKILL.md index 8a7dbdee0..cbd923cbd 100644 --- a/plugins/nytimes/skills/python/python-integrate-the-new-york-times-apis/SKILL.md +++ b/plugins/nytimes/skills/python/python-integrate-the-new-york-times-apis/SKILL.md @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `python-getting-started` is your **lookup layer - **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import the_new_york_times_apis, pathlib; print(pathlib.Path(the_new_york_times_apis.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import the_new_york_times_apis, pathlib; print(pathlib.Path(the_new_york_times_apis.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) - **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. @@ -40,6 +40,21 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** creating or activating a virtual environment, installing `the-new-york-times-apis` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. @@ -62,8 +77,9 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. 4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `the_new_york_times_apis.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `the_new_york_times_apis.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. ### Step 3 — Answering pure questions diff --git a/plugins/nytimes/skills/python/python-models/SKILL.md b/plugins/nytimes/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/nytimes/skills/python/python-models/SKILL.md +++ b/plugins/nytimes/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/nytimes/skills/python/python-models/reference.md b/plugins/nytimes/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/nytimes/skills/python/python-models/reference.md +++ b/plugins/nytimes/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/nytimes/skills/python/python-testing/SKILL.md b/plugins/nytimes/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/nytimes/skills/python/python-testing/SKILL.md +++ b/plugins/nytimes/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/nytimes/skills/typescript/typescript-authentication/SKILL.md b/plugins/nytimes/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/nytimes/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/nytimes/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/nytimes/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/nytimes/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/nytimes/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/nytimes/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/nytimes/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/nytimes/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/nytimes/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/nytimes/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/nytimes/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/nytimes/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/nytimes/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/nytimes/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/nytimes/skills/typescript/typescript-error-handling/SKILL.md b/plugins/nytimes/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/nytimes/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/nytimes/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/nytimes/skills/typescript/typescript-getting-started/SKILL.md b/plugins/nytimes/skills/typescript/typescript-getting-started/SKILL.md index 5a7c573d3..e9234f6ed 100644 --- a/plugins/nytimes/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/nytimes/skills/typescript/typescript-getting-started/SKILL.md @@ -20,15 +20,17 @@ Verified against `package.json` and `sdk-map.md` of the generated package at ver | API | The New York Times APIs | | Package name (what you install, and what you import) | `the-new-york-times-apis` — **not on npm**; built from source (see *Install*) | | Import specifier | `the-new-york-times-apis` — the package root is the **only** entry; deep imports do not resolve | +| Source repository | https://github.com/context-plugins/nytimes-typescript-sdk (branch `main` — the ref this map documents) | | Version | `1.0.0` (API spec version `1.0.0`) | | Client class | `TheNewYorkTimesApIsClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new TheNewYorkTimesApIsClient(clientOptions: Partial = {})` — **every** field is optional, so `new TheNewYorkTimesApIsClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `apikey`. `timeout` defaults to `60_000` ms | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new TheNewYorkTimesApIsClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new TheNewYorkTimesApIsClient()` compiles. Fields: `serverOptions` · `retry` · `fetch` · `apikey`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **API key (query)** — set `ClientOptions.apikey` | -| Environments | 1 environment (`ServerEnvironment.Production` *(default)*) × 1 server group | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`), defaulting to `https://api.nytimes.com` | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Environments | no selector — one environment, so none is emitted; 1 server group | +| Base-URL config | `serverOptions.baseUrl` (`src/servers.ts`), defaulting to `https://api.nytimes.com` | +| Log environment variable | `THE_NEW_YORK_TIMES_AP_IS_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Surface | 10 operations · 6 resources · 45 models · 5 open enums · 9 per-operation error subclasses | @@ -37,22 +39,22 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/nytimes-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 50 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, 50 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { TheNewYorkTimesApIsClient, ServerEnvironment, ResponseError, TheNewYorkTimesApIsError } from "the-new-york-times-apis"; +import { TheNewYorkTimesApIsClient, ApiError, TheNewYorkTimesApIsError } from "the-new-york-times-apis"; import type { ClientOptions, Article } from "the-new-york-times-apis"; ``` @@ -65,21 +67,19 @@ Things the specifier alone will not tell you: Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, every model type) must be imported with `import type`. Under `exactOptionalPropertyTypes`, **omit or spread** an absent optional field rather than assigning `undefined` to it. -## Environments +## Hosts -`ClientOptions.serverEnvironment` selects one environment for the whole client (`src/servers.ts`). `ServerEnvironment` is a `const` object with a derived union type — not a TypeScript `enum` — and unlike the model enums it is **closed**, so only its declared members are assignable. +This API declares one environment, so the SDK has **no environment selector** — there is no `ServerEnvironment` type and no field to set (`src/servers.ts`). The host below is the only one the SDK knows, and the override in the last column is the only way to reach another. -| Group | Environment | Base URL | Override at | -| --- | --- | --- | --- | -| `default` | `production` *(default)* | `https://api.nytimes.com` | `serverOptions.default.production.baseUrl` | +| Group | Base URL | Override at | +| --- | --- | --- | +| `default` | `https://api.nytimes.com` | `serverOptions.baseUrl` | Consequences to state on every contract sheet that touches configuration: -- Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- ⚠ **This SDK declares exactly one environment**, so reaching any other host is a `serverOptions` override, not an environment value — `serverOptions: { default: { production: { baseUrl: "…" } } }`. Note the key is still `production`: the environment name and the host it points at are now decoupled, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the deployment's intent explicit where the client is built, and verify it. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- ⚠ **This SDK declares exactly one environment**, so it emits no `ServerEnvironment` at all and reaching any other host is a `serverOptions` base-URL override — `serverOptions: { baseUrl: "…" }`. Nothing in the options then records **which** deployment this is, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the intent explicit where the client is built, and verify it. +- An override merges with the built-in default **key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. ## Auth pattern (1 scheme) @@ -101,7 +101,7 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. See `typescript-authentication` for the full picture. @@ -118,7 +118,7 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | `client.books` | `Books` | 2 | `getListsDateListJson` · `getListsOverviewJson` | | `client.rss` | `Rss` | 1 | `getSectionFeed` | -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** A name that collides inside its namespace is promoted to `RequestParams` instead — no operation in this SDK takes that spelling at this version. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -126,7 +126,7 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: @@ -135,11 +135,11 @@ Locate the installed package before you rely on a lookup: node -e "console.log(require.resolve('the-new-york-times-apis/package.json'))" ``` -Failing that it is at `node_modules/the-new-york-times-apis/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/the-new-york-times-apis/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/the-new-york-times-apis/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -147,7 +147,7 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/the-new-york-times-apis/`: @@ -157,13 +157,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -177,30 +179,38 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `THE_NEW_YORK_TIMES_AP_IS_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new TheNewYorkTimesApIsClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new TheNewYorkTimesApIsClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) 2. **Authentication** — load **typescript-authentication** before you set credentials. The scheme is `apikey` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **One operation resolves to `undefined` (`rss.getSectionFeed`)**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `TheNewYorkTimesApIsError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `TheNewYorkTimesApIsError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `THE_NEW_YORK_TIMES_AP_IS_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK Beyond the usual signatures and model members, a contract sheet for the The New York Times APIs TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. -1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment, `ServerEnvironment.Production`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. +1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment and emits no `ServerEnvironment`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. 2. **One operation resolves to `undefined` (`rss.getSectionFeed`)** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 9 of 10 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. **That `Response` imported from this package is a model type, not the global of that name** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `TheNewYorkTimesApIsError` for the same reason. -10. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 9 of 10 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. **That `Response` imported from this package is a model type, not the global of that name** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `TheNewYorkTimesApIsError` for the same reason. +12. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/nytimes/skills/typescript/typescript-integrate-the-new-york-times-apis/SKILL.md b/plugins/nytimes/skills/typescript/typescript-integrate-the-new-york-times-apis/SKILL.md index 47d51b172..b5ebe7207 100644 --- a/plugins/nytimes/skills/typescript/typescript-integrate-the-new-york-times-apis/SKILL.md +++ b/plugins/nytimes/skills/typescript/typescript-integrate-the-new-york-times-apis/SKILL.md @@ -1,6 +1,6 @@ --- name: "typescript-integrate-the-new-york-times-apis" -description: "MANDATORY FIRST STEP for The New York Times APIs TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate The New York Times APIs in TypeScript/Node — search articles, get archive by month, get top stories by section, get most emailed articles, get most shared articles, get most shared articles by type, or when the The New York Times APIs TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `the-new-york-times-apis` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (the-new-york-times-apis-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares only ServerEnvironment.Production, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." +description: "MANDATORY FIRST STEP for The New York Times APIs TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate The New York Times APIs in TypeScript/Node — search articles, get archive by month, get top stories by section, get most emailed articles, get most shared articles, get most shared articles by type, or when the The New York Times APIs TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `the-new-york-times-apis` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (the-new-york-times-apis-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares one environment and no ServerEnvironment, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." --- # The New York Times APIs TypeScript SDK — Integration workflow (lookup layer + contract sheet) @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/the-new-york-times-apis/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('the-new-york-times-apis/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('the-new-york-times-apis/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. @@ -23,14 +23,14 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/the-new-york-times-apis-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/the-new-york-times-apis-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, **module format — ESM or CJS, which changes how the SDK is imported and whether `instanceof` on its error classes is trustworthy**, and the runtime: Node, a bundled browser app, or an edge runtime) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - **establish the toolchain before you need it**: the package manager, whether `the-new-york-times-apis` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; - a baseline run of the project's checks (`tsc --noEmit`, the test runner, the linter — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment (`ServerEnvironment.Production`), so **any other host is a `serverOptions` base-URL override rather than an environment value**; +- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment, so it emits no `ServerEnvironment` at all and **any other host is a `serverOptions` base-URL override rather than an environment value**; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your credential may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; - setting up your task tracking. @@ -40,11 +40,85 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** installing `the-new-york-times-apis` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. @@ -58,13 +132,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp ### Step 2 — Implement from the contract sheet 1. Read `the-new-york-times-apis-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment — `ServerEnvironment.Production` — so a different host is a `serverOptions` override under a key still named after `production`: the environment name and the host it points at are decoupled, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. +2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment and emits no selector for it, so a different host is a `serverOptions` base-URL override. Nothing in the options then records **which** deployment this is, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. 3. **Build the client once and hold it.** Resources are memoized lazy getters on it, so rebuilding one per request throws that state away. There is no `close()` — it owns a `fetch`, not a pool. 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from the **one operation that resolves to `undefined`** (`rss.getSectionFeed`). `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `TheNewYorkTimesApIsError` family, told apart by `err.kind`. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `THE_NEW_YORK_TIMES_AP_IS_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions @@ -74,7 +150,7 @@ A standalone The New York Times APIs question with no code change: look it up in - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. - **Never write any The New York Times APIs or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. - **Don't web-search The New York Times APIs topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public The New York Times APIs docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. diff --git a/plugins/nytimes/skills/typescript/typescript-models/SKILL.md b/plugins/nytimes/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/nytimes/skills/typescript/typescript-models/SKILL.md +++ b/plugins/nytimes/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/nytimes/skills/typescript/typescript-models/reference.md b/plugins/nytimes/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/nytimes/skills/typescript/typescript-models/reference.md +++ b/plugins/nytimes/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/nytimes/skills/typescript/typescript-testing/SKILL.md b/plugins/nytimes/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/nytimes/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/nytimes/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/paypal/.claude-plugin/plugin.json b/plugins/paypal/.claude-plugin/plugin.json index eea0e2153..8345bdaef 100644 --- a/plugins/paypal/.claude-plugin/plugin.json +++ b/plugins/paypal/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "paypal", "displayName": "Paypal", - "version": "0.3.3", + "version": "0.4.0", "description": "Integrate and consume the APIMatic-generated PayPal Server SDK SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/paypal/.cursor-plugin/plugin.json b/plugins/paypal/.cursor-plugin/plugin.json index eea0e2153..8345bdaef 100644 --- a/plugins/paypal/.cursor-plugin/plugin.json +++ b/plugins/paypal/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "paypal", "displayName": "Paypal", - "version": "0.3.3", + "version": "0.4.0", "description": "Integrate and consume the APIMatic-generated PayPal Server SDK SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/paypal/plugin.json b/plugins/paypal/plugin.json index eea0e2153..8345bdaef 100644 --- a/plugins/paypal/plugin.json +++ b/plugins/paypal/plugin.json @@ -1,7 +1,7 @@ { "name": "paypal", "displayName": "Paypal", - "version": "0.3.3", + "version": "0.4.0", "description": "Integrate and consume the APIMatic-generated PayPal Server SDK SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/paypal/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/paypal/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..802240bf1 100644 --- a/plugins/paypal/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/paypal/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,67 +1,28 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Paypal .NET SDK in C# — this API authenticates with OAuth 2.0 client credentials, and this skill gives the credential each scheme takes, the PaypalClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Paypal .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with OAuth 2.0 client credentials. APIMatic surfaces each scheme as a **nullable credentials property** on `PaypalClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) - -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). - -## Basic auth - -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` - -Sends `Authorization: Basic base64(username:password)`. - -## Bearer token - -Set the configured token property on the options class to your access-token string: - -```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; -``` - -Sends `Authorization: Bearer ACCESS_TOKEN`. +| Scheme | Property on `PaypalClientOptions` | Credential | +| --- | --- | --- | +| OAuth 2.0 client credentials | `Oauth2` | `OAuth2ClientCredentials` | -## API key (header, query, or cookie) +**That table is the entire authentication surface of this SDK.** `Paypal.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: +## OAuth 2.0 — client credentials (machine-to-machine) -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` +**API Provider Notes:** -## OAuth 2.0 — client credentials (machine-to-machine) +> Oauth 2.0 authentication, OAuth 2.0 authentication, Oauth 2.0 authentication, Oauth 2.0 authentication, Oauth 2.0 authentication ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; +using Paypal.Core.Authentication.OAuth2.ClientCredentials; -options.{OAuthProperty} = new OAuth2ClientCredentials +options.Oauth2 = new OAuth2ClientCredentials { ClientId = "...", ClientSecret = "...", @@ -69,177 +30,72 @@ options.{OAuthProperty} = new OAuth2ClientCredentials }; ``` -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. +The SDK fetches the token from `https://api-m.sandbox.paypal.com/v1/oauth2/token` and caches it, acquiring a fresh one when it expires; on a `401` it invalidates the cached token and re-acquires. The token request carries the client id and secret as HTTP Basic credentials, so `ClientSecret` is required. -## OAuth 2.0 — authorization code (3-legged, with PKCE) +## Token caching & refresh -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; +- Tokens are cached in-memory, **per client instance**, and reused until **30s** before expiry. +- **Nothing here refreshes.** The grants above are not wired to `IOAuth2RefreshableTokenStrategy` — when a token expires the whole grant re-runs. A `refresh_token` in the response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do not infer refresh behaviour from what the provider returns. +- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is **not** retried. +- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of a fresh token. -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials -{ - ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE - RedirectUri = "https://app.example.com/callback", - Scope = "...", // optional - State = "...", // optional CSRF token - Pkce = PkceMethod.S256, // default; RFC 7636 - PromptForAuthorizationCode = async (authorizationUrl, ct) => - { - // Open/redirect the browser to authorizationUrl, then return the - // authorization code your redirect endpoint received. - return await GetCodeFromUserAsync(authorizationUrl, ct); - } -}; -``` +⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** `expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to "not expired" when it is absent — so the first token is cached for the life of the client and the only thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. +### Supplying your own token source -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: +The token strategy is a public extension point: `PaypalClientOptions` exposes a strategy property beside each credentials property, and the generated client falls back to the built-in strategy only when you leave it null. The strategy is used only when the credentials property beside it is also set, and `GetToken` receives that object. -| PKCE | token request built | no secret ⇒ | +| Grant | Strategy property on `PaypalClientOptions` | Type | | --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | - -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. - -## OAuth 2.0 — resource owner password - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; - -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` - -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. - -### Supplying your own token source - -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. +| Client credentials | `Oauth2TokenStrategy` | `IOAuth2TokenStrategy` | ```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) +options.Oauth2 = new OAuth2ClientCredentials { /* its required members */ }; +options.Oauth2TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) ``` -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: - -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). - -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. - -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test double. The per-client in-memory cache above still wraps whatever you return. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `PaypalClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("PaypalClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.ClientId)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "PaypalClient:ClientId is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"PaypalClient:ClientId is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. + +Check every value the schemes actually need. For this SDK that is: -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +| Value to validate | Feeds `PaypalClientOptions` property | +| --- | --- | +| `ClientId` | `Oauth2` | +| `ClientSecret` | `Oauth2` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddPaypalClient(options => ...)` callback when registering via DI. `PaypalClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddPaypalClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/paypal/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/paypal/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/paypal/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/paypal/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/paypal/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/paypal/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/paypal/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/paypal/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/paypal/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/paypal/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/paypal/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/paypal/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/paypal/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/paypal/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/paypal/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/paypal/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/paypal/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/paypal/skills/dotnet/dotnet-getting-started/SKILL.md index fb7d5c267..97d6849c3 100644 --- a/plugins/paypal/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/paypal/skills/dotnet/dotnet-getting-started/SKILL.md @@ -1,120 +1,123 @@ --- name: "dotnet-getting-started" -description: "PayPal Server SDK .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-paypal is the workflow that uses it." +description: "Paypal .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-paypal is the workflow that uses it." --- -# Getting started with the PayPal Server SDK .NET SDK +# Getting started with the Paypal .NET SDK -> **Who this skill is for.** This is the **map layer**: load it to ground every PayPal Server SDK SDK fact. `dotnet-integrate-paypal` is the workflow that drives it — you collect the contracts for the work in scope into `pay-pal-server-sdk-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. +> **Who this skill is for.** This is the **map layer**: load it to ground every Paypal SDK fact. `dotnet-integrate-paypal` is the workflow that drives it — you collect the contracts for the work in scope into `paypal-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated .NET SDK (auth, calling endpoints, models, error handling, retries, testing), see the companion API-agnostic skills: `dotnet-client-initialization`, `dotnet-authentication`, `dotnet-calling-endpoints`, `dotnet-models`, `dotnet-error-handling`, `dotnet-configuration-resilience`, `dotnet-testing`. **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity | | | | --- | --- | -| API | PayPal Server SDK | +| API | Paypal | | NuGet package | *not published to NuGet — see *Install* below* | | Source repo | https://github.com/context-plugins/paypal-csharp-sdk (branch `main` — the ref this map documents) | -| Root namespace | `PayPalServerSdk` (the `using` namespace) | -| Client class | `PayPalServerSdkClient` | -| Options class | `PayPalServerSdkClientOptions` | +| Root namespace | `Paypal` (the `using` namespace) | +| Client class | `PaypalClient` | +| Options class | `PaypalClientOptions` | | Auth | **OAuth 2.0** client credentials — set `Oauth2` | | Environments | `ServerEnvironment.Sandbox` *(default)* → `https://api-m.sandbox.paypal.com` | -| Log environment variable | `PAYPALSERVERSDKCLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Log environment variable | `PAYPALCLIENT_LOG` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-paypal` makes that binding); the **Integration workflow** below says which governs which step. ## Namespaces (using-directives) -The SDK splits its public types across **separate child namespaces**. C# does **not** import child namespaces transitively, so `using PayPalServerSdk.Models;` alone does **not** make enums, union types, or error types visible — you get `CS0103`/`CS0246` ("name/type does not exist") on build. Add a separate `using` for each kind of type you reference: +The SDK splits its public types across **separate child namespaces**. C# does **not** import child namespaces transitively, so `using Paypal.Models;` alone does **not** make enums, union types, or error types visible — you get `CS0103`/`CS0246` ("name/type does not exist") on build. Add a separate `using` for each kind of type you reference: | Type | Namespace | | --- | --- | -| `PayPalServerSdkClient` | `PayPalServerSdk` | -| `PayPalServerSdkClientOptions` | `PayPalServerSdk` | -| `ServerEnvironment` | `PayPalServerSdk.Servers` | +| `PaypalClient` | `Paypal` | +| `PaypalClientOptions` | `Paypal` | +| `ServerEnvironment` | `Paypal.Servers` | Models, enums, union types and the generated error types each sit in their own child namespace. The map's **Namespaces by content type** table maps each source directory to its namespace, so read the namespace off the path the map gives for the type; anything left ambiguous is settled by the `namespace` declaration at the top of the type's own file. ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Paypal, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/Paypal/Models/`, `src/Paypal/Errors/` and `src/Paypal/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/Paypal/Models/`, enums `src/Paypal/Models/Enums/`, typed errors `src/Paypal/Errors/`, request records `src/Paypal/Requests/`, or `src/Paypal/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: -- Collect the contracts for **every** in-scope operation in **one** pass — signatures and error accessors from the map pages, then required fields with wire names and enum values from the declaring files those pages name — into the **contract sheet** in `pay-pal-server-sdk-plan.md`, then implement from the sheet. Do not re-open a map page or source file per field, and never re-look-up a fact the sheet already carries. +- Collect the contracts for **every** in-scope operation in **one** pass — signatures and error accessors from the map pages, then required fields with wire names and enum values from the declaring files those pages name — into the **contract sheet** in `paypal-plan.md`, then implement from the sheet. Do not re-open a map page or source file per field, and never re-look-up a fact the sheet already carries. - When you do open a source file, read it **scoped**: with an offset/limit, or a search for the one symbol — never dump a whole file into the conversation. - Never open the SDK's `api-reference.md` — the map supersedes it. ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/pay-pal-server-sdk-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/paypal-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. -**Clone once, up front**, into a fresh timestamped folder under `/pay-pal-server-sdk-sdk-src/`, and reuse that folder for the rest of your session: +**Clone once, up front**, into a fresh timestamped folder under `/paypal-sdk-src/`, and reuse that folder for the rest of your session: ```bash # Linux/macOS: -dir="${TMPDIR:-/tmp}/pay-pal-server-sdk-sdk-src/$(date +%Y%m%d-%H%M%S)" +dir="${TMPDIR:-/tmp}/paypal-sdk-src/$(date +%Y%m%d-%H%M%S)" git clone --filter=blob:none --branch main https://github.com/context-plugins/paypal-csharp-sdk "$dir" # Reuse "$dir" for the rest of your session (it is your clone path). ``` ```powershell # Windows (PowerShell): -$dir = "$env:TEMP\pay-pal-server-sdk-sdk-src\$(Get-Date -Format yyyyMMdd-HHmmss)" +$dir = "$env:TEMP\paypal-sdk-src\$(Get-Date -Format yyyyMMdd-HHmmss)" git clone --filter=blob:none --branch main https://github.com/context-plugins/paypal-csharp-sdk $dir # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/pay-pal-server-sdk-sdk-src/`, **never** in the project repo, and its path never goes into `pay-pal-server-sdk-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `paypal-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `PayPalServerSdkClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/Paypal/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/Paypal/Models/` (+ `src/Paypal/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/Paypal/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/Paypal/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/Paypal/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/Paypal/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/Paypal/Servers/`, `src/Paypal/PaypalClient.cs`, `src/Paypal/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one -The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and PayPal Server SDK may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. +The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Paypal may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything -Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's Type sources table names it) lists every field with its `[JsonPropertyName]` wire name. If the scope touches such a field, three generator facts decide your logging posture (all in `dotnet-configuration-resilience`): JSON request bodies are logged **verbatim** when `LogRequestBody` is on, with no redaction; form bodies are masked only by deny-list; and leaving `options.Logging.LoggerFactory` unset arms the `PAYPALSERVERSDKCLIENT_LOG` environment variable, whose `trace` level forces body logging on with no code change and no deploy. So on any build that can carry such a field: `LogRequestBody` stays off, `LoggerFactory` is set explicitly in production, and your own diagnostics never echo a request body on those paths. +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's Type sources table names it) lists every field with its `[JsonPropertyName]` wire name. If the scope touches such a field, three generator facts decide your logging posture (all in `dotnet-configuration-resilience`): JSON request bodies are logged **verbatim** when `LogRequestBody` is on, with no redaction; form bodies are masked only by deny-list; and leaving `options.Logging.LoggerFactory` unset arms the `PAYPALCLIENT_LOG` environment variable, whose `trace` level forces body logging on with no code change and no deploy. So on any build that can carry such a field: `LogRequestBody` stays off, `LoggerFactory` is set explicitly in production, and your own diagnostics never echo a request body on those paths. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. @@ -126,17 +129,17 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/Paypal/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step You loaded every companion the sheet named before starting (per `dotnet-integrate-paypal`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new PayPalServerSdkClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) -2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `PayPalServerSdkClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new PaypalClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) +2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `PaypalClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/paypal/skills/dotnet/dotnet-integrate-paypal/SKILL.md b/plugins/paypal/skills/dotnet/dotnet-integrate-paypal/SKILL.md index f415e4547..dea6ad173 100644 --- a/plugins/paypal/skills/dotnet/dotnet-integrate-paypal/SKILL.md +++ b/plugins/paypal/skills/dotnet/dotnet-integrate-paypal/SKILL.md @@ -1,35 +1,35 @@ --- name: "dotnet-integrate-paypal" -description: "MANDATORY FIRST STEP for PayPal Server SDK .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate PayPal Server SDK in C# — Orders, Payments, Subscriptions, TransactionSearch, Vault — or when a PayPal Server SDK .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file pay-pal-server-sdk-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." +description: "MANDATORY FIRST STEP for Paypal .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Paypal in C# — Orders, Payments, Subscriptions, TransactionSearch, Vault — or when a Paypal .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file paypal-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." --- -# PayPal Server SDK .NET SDK — Workflow (map + skills) +# Paypal .NET SDK — Workflow (map + skills) -You are the PayPal Server SDK .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. +You are the Paypal .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. -**Scope guard:** the APIMatic-generated PayPal Server SDK **.NET SDK** (root namespace `PayPalServerSdk`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. +**Scope guard:** the APIMatic-generated Paypal **.NET SDK** (root namespace `Paypal`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. ## Grounding rules -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `pay-pal-server-sdk-plan.md` — the plan must stay portable. +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `paypal-plan.md` never carries its path — the plan must stay portable. - **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. - **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. - **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. -- **The sheet records PayPal Server SDK's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. +- **The sheet records Paypal's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. - **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. ## Workflow -**If the user opens with a reported SDK error or unexpected PayPal Server SDK behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: +**If the user opens with a reported SDK error or unexpected Paypal behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: -### Step 1 — Plan first: write `pay-pal-server-sdk-plan.md` (always, for any implementation work) +### Step 1 — Plan first: write `paypal-plan.md` (always, for any implementation work) -Your FIRST deliverable is `pay-pal-server-sdk-plan.md` at `/pay-pal-server-sdk-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. +Your FIRST deliverable is `paypal-plan.md` at `/paypal-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. #### How to ground (map-first, one pass) 1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. 3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. 4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. 5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. @@ -38,7 +38,7 @@ Your FIRST deliverable is `pay-pal-server-sdk-plan.md` at `/p These need no SDK knowledge and touch no project file, so do them before or alongside the map work: -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); - the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; - locating the SDK reference and its version in the project; @@ -49,15 +49,28 @@ Never use this prerequisite work as a "head start" on implementation: **creating #### HARD GATE -**No project-file creation or edits until `pay-pal-server-sdk-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. +**No project-file creation or edits until `paypal-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. -#### The `pay-pal-server-sdk-plan.md` format (keep it tight — tables, not prose) +#### The `paypal-plan.md` format (keep it tight — tables, not prose) 1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + 3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. 6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. 7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. @@ -67,12 +80,46 @@ Never use this prerequisite work as a "head start" on implementation: **creating | 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | | 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | | 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | | 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | | 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | | 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. @@ -84,42 +131,43 @@ These are API-agnostic usage skills; loading them is not the same as reading the Before implementing, check the plan's **Assumptions & Blockers** section: -- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `pay-pal-server-sdk-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. +- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `paypal-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. - Minor assumptions only → proceed. Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. ### Step 3 — Implement from the contract sheet -1. Read `pay-pal-server-sdk-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. +1. Read `paypal-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. 2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. 3. After every change: `dotnet build`; fix non-SDK errors yourself. -4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `PayPalServerSdk.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. +4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `Paypal.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. ### Step 4 — Fixing SDK errors (map-first, in place) -A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `PayPalServerSdk.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. +A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `Paypal.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/Paypal/Models/{Type}.cs`, `src/Paypal/Models/Enums/{Type}.cs`, `src/Paypal/Errors/{Type}.cs`, `src/Paypal/Requests/{Type}.cs`, or `src/Paypal/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. 3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". 4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). 5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. 6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. -If a fix corrects a row in `pay-pal-server-sdk-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. +If a fix corrects a row in `paypal-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. ### Step 5 — Pure questions -A standalone PayPal Server SDK question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. +A standalone Paypal question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. ## Anti-patterns — never do these -- **Never write a PayPal Server SDK/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** +- **Never write a Paypal/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** - **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. - **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. - **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search PayPal Server SDK for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `PayPalServerSdk` is what the generated source declares, not what public docs describe. +- **Do not web-search Paypal for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `Paypal` is what the generated source declares, not what public docs describe. - **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. diff --git a/plugins/paypal/skills/dotnet/dotnet-models/SKILL.md b/plugins/paypal/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/paypal/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/paypal/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/paypal/skills/dotnet/dotnet-models/reference.md b/plugins/paypal/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/paypal/skills/dotnet/dotnet-models/reference.md +++ b/plugins/paypal/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/paypal/skills/dotnet/dotnet-testing/SKILL.md b/plugins/paypal/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/paypal/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/paypal/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/paypal/skills/python/python-authentication/SKILL.md b/plugins/paypal/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/paypal/skills/python/python-authentication/SKILL.md +++ b/plugins/paypal/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/paypal/skills/python/python-calling-endpoints/SKILL.md b/plugins/paypal/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/paypal/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/paypal/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/paypal/skills/python/python-client-initialization/SKILL.md b/plugins/paypal/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/paypal/skills/python/python-client-initialization/SKILL.md +++ b/plugins/paypal/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/paypal/skills/python/python-configuration-resilience/SKILL.md b/plugins/paypal/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/paypal/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/paypal/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/paypal/skills/python/python-error-handling/SKILL.md b/plugins/paypal/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/paypal/skills/python/python-error-handling/SKILL.md +++ b/plugins/paypal/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/paypal/skills/python/python-event-streams/SKILL.md b/plugins/paypal/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/paypal/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/paypal/skills/python/python-file-handling/SKILL.md b/plugins/paypal/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/paypal/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/paypal/skills/python/python-getting-started/SKILL.md b/plugins/paypal/skills/python/python-getting-started/SKILL.md index 86e907e8c..7f37ec635 100644 --- a/plugins/paypal/skills/python/python-getting-started/SKILL.md +++ b/plugins/paypal/skills/python/python-getting-started/SKILL.md @@ -1,11 +1,11 @@ --- name: "python-getting-started" -description: "PayPal Server SDK Python SDK identity and lookup layer (Python only) — install, import root, base URL/environments, the auth pattern, the SDK map that ships at the SDK root (`sdk-map.md` + `map/operations/`) and how to traverse it, and the module table naming the one file owning each fact the map leaves to the source. Load this before answering any PayPal Server SDK Python SDK contract question or writing any SDK code." +description: "Paypal Python SDK identity and lookup layer (Python only) — install, import root, base URL/environments, the auth pattern, the SDK map that ships at the SDK root (`sdk-map.md` + `map/operations/`) and how to traverse it, and the module table naming the one file owning each fact the map leaves to the source. Load this before answering any Paypal Python SDK contract question or writing any SDK code." --- -# Getting started with the PayPal Server SDK Python SDK +# Getting started with the Paypal Python SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing PayPal Server SDK Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-paypal` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Paypal Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-paypal` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated Python SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `python-client-initialization`, `python-authentication`, `python-calling-endpoints`, `python-models`, `python-error-handling`, `python-configuration-resilience` and `python-testing`. @@ -13,24 +13,24 @@ This is the **SDK-specific** entry point. For general patterns that apply to any ## SDK identity -Verified against `pay_pal_server_sdk/` and `pyproject.toml` of the generated package at version `2.29`. **Re-verify after a version bump** — this page is a snapshot, not a live read. +Verified against `paypal/` and `pyproject.toml` of the generated package at version `2.29`. **Re-verify after a version bump** — this page is a snapshot, not a live read. | Fact | Value | | --- | --- | -| API | PayPal Server SDK | -| Distribution name (what you install) | `pay-pal-server-sdk` — **not on any package index**; installed from source (see *Install*) | -| Import root (what you import) | `pay_pal_server_sdk` — not the string you install | +| API | Paypal | +| Distribution name (what you install) | `paypal` — **not on any package index**; installed from source (see *Install*) | +| Import root (what you import) | `paypal` — the same string you install | | Source repository | https://github.com/context-plugins/paypal-python-sdk | | Source branch | `main` | | Version | `2.29` | -| Sync client class | `PayPalServerSdkClient` (alias `Client`) | -| Async client class | `AsyncPayPalServerSdkClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`) · `oauth2` · `oauth2_token_source`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Sync client class | `PaypalClient` (alias `Client`) | +| Async client class | `AsyncPaypalClient` (alias `AsyncClient`) | +| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`) · `retry_options` · `oauth2` · `oauth2_token_source`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | **OAuth 2.0** client credentials — set `oauth2` | | Environments | **no environment enum** — one `base_url` string, defaulting to `https://api-m.sandbox.paypal.com` | -| Base-URL config | `ServerConfig` (`pay_pal_server_sdk/server/server_config.py`), frozen, `extra="forbid"` | +| Base-URL config | `ServerConfig` (`paypal/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | | Surface | 40 operations across 5 controllers · 284 models · 0 unions · 87 enums · 39 per-operation error unions | @@ -39,26 +39,31 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash -pip install "pay-pal-server-sdk @ git+https://github.com/context-plugins/paypal-python-sdk.git@main" +pip install "paypal @ git+https://github.com/context-plugins/paypal-python-sdk.git@main" +python -c "import paypal, pathlib; print(pathlib.Path(paypal.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules -Python does not re-export child modules transitively, so `from pay_pal_server_sdk import models` alone does **not** make enums, error unions, or runtime types reachable. Import each kind of type from the module that owns it. +Python does not re-export child modules transitively, so `from paypal import models` alone does **not** make enums, error unions, or runtime types reachable. Import each kind of type from the module that owns it. -`pay_pal_server_sdk/__init__.py` exports exactly 5 names beside the `pay_pal_server_sdk.models` subpackage it re-exports: +`paypal/__init__.py` exports exactly 5 names beside the `paypal.models` subpackage it re-exports: ```python -from pay_pal_server_sdk import ( +from paypal import ( AsyncClient, - AsyncPayPalServerSdkClient, + AsyncPaypalClient, Client, - PayPalServerSdkClient, + PaypalClient, ServerConfig, ) ``` @@ -67,16 +72,16 @@ Everything else comes from its own subpackage, and the split matters because eac | What you need | Where it lives | | --- | --- | -| Domain models, their `…Dict` companions | `pay_pal_server_sdk.models` | -| Enums (and their open `…OrStr` aliases) | `pay_pal_server_sdk.models.enums` | -| `ApiError` · `RawError` · `ApiResult` · `RequestOptions` · `ClientCredentials` · `HttpClient` · `SdkBaseModel` · `UNSET` · `Optional` | `pay_pal_server_sdk.core` | -| Per-operation error *unions* | `pay_pal_server_sdk.errors` (`ActivateBillingPlanErrorBody`, …) | +| Domain models, their `…Dict` companions | `paypal.models` | +| Enums (and their open `…OrStr` aliases) | `paypal.models.enums` | +| `ApiError` · `RawError` · `ApiResult` · `RequestOptions` · `ClientCredentials` · `HttpClient` · `SdkBaseModel` · `UNSET` · `Optional` | `paypal.core` | +| Per-operation error *unions* | `paypal.errors` (`ActivateBillingPlanErrorBody`, …) | -`pay_pal_server_sdk.core` re-exports its whole public surface (a curated `__all__`), so import from `…core` rather than from the private modules beneath it (`…core.results`, `…core.exceptions`, `…core.auth.schemes`). +`paypal.core` re-exports its whole public surface (a curated `__all__`), so import from `…core` rather than from the private modules beneath it (`…core.results`, `…core.exceptions`, `…core.auth.schemes`). ## Environments — there is no environment enum -This SDK has **no environment type and no environment constants**. There is one knob: `base_url`, on `ServerConfig` (`pay_pal_server_sdk/server/server_config.py`), and its default is **`https://api-m.sandbox.paypal.com`**: +This SDK has **no environment type and no environment constants**. There is one knob: `base_url`, on `ServerConfig` (`paypal/server/server_config.py`), and its default is **`https://api-m.sandbox.paypal.com`**: ```python base_url: str = "https://api-m.sandbox.paypal.com" @@ -94,8 +99,8 @@ Consequences to state on every contract sheet that touches configuration: OAuth 2.0 client credentials, exposed as the client's `oauth2=` keyword taking `ClientCredentials` **or a plain dict**. The client fetches and caches the bearer token itself, lazily, from `/v1/oauth2/token`. ```python -from pay_pal_server_sdk import Client -from pay_pal_server_sdk.core import ClientCredentials +from paypal import Client +from paypal.core import ClientCredentials client = Client(oauth2=ClientCredentials(client_id="…", client_secret="…")) client = Client(oauth2={"client_id": "…", "client_secret": "…"}) # equivalent @@ -123,24 +128,24 @@ The SDK map's controller table carries the same counts and links to a page per c ## SDK map — look up first, open the module second -The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `pay_pal_server_sdk/` source directory, and these two entries: +The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `paypal/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `pay_pal_server_sdk/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `paypal/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/paypal-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. -Every `Source` path on the map is relative to that SDK root, so `pay_pal_server_sdk/models/activate_subscription_request.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. +Every `Source` path on the map is relative to that SDK root, so `paypal/models/activate_subscription_request.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. **The map is the locator; the source modules are the shapes.** Read the map first — signatures, routes, parameter roles, return types, error unions, and which module declares a type are all answered there without opening a single `.py` file. Then open the one module the map names for what it deliberately does not carry: a model's members, an enum's values, a field's wire alias. The map says so itself — *"Shapes live only in the source … Never grep for a type."* -**The map carries shapes; what an operation *means* lives elsewhere.** When *what* to pass depends on meaning — which values a field accepts beyond its type, a rule that couples two fields, what a defaulted parameter actually selects — the map will not settle it. Open the operation's docstring in `pay_pal_server_sdk/apis/.py` (or `client.py` where operations sit on the client) and `api-reference.md` at the SDK root for that operation *before* writing the sheet row, and record what you found there. A value you already "know" for a field the map types as a plain `str` is a lookup, not a recall — the memory ban applies to it. +**The map carries shapes; what an operation *means* lives elsewhere.** When *what* to pass depends on meaning — which values a field accepts beyond its type, a rule that couples two fields, what a defaulted parameter actually selects — the map will not settle it. Open the operation's docstring in `paypal/apis/.py` (or `client.py` where operations sit on the client) and `api-reference.md` at the SDK root for that operation *before* writing the sheet row, and record what you found there. A value you already "know" for a field the map types as a plain `str` is a lookup, not a recall — the memory ban applies to it. `sdk-map.md` carries the invariants every operation block assumes, so load it before any `map/operations/` page; the pages are written to be read beside it. @@ -151,10 +156,10 @@ Every `Source` path on the map is relative to that SDK root, so `pay_pal_server_ Read the one module that owns the fact **inside the installed package**. Locate it first: ```bash -python -c "import pay_pal_server_sdk, pathlib; print(pathlib.Path(pay_pal_server_sdk.__file__).parent)" +python -c "import paypal, pathlib; print(pathlib.Path(paypal.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/pay_pal_server_sdk` on Windows, `.venv/lib/python3.*/site-packages/pay_pal_server_sdk` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/paypal` on Windows, `.venv/lib/python3.*/site-packages/paypal` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -162,7 +167,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/pa | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -172,7 +177,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/pa | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | | An operation's error mapper (status → schema) | `errors/_error.py` | @@ -189,13 +194,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The one scheme is `oauth2=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; the token is fetched lazily and cached by the client; and a **failed token fetch** raises something other than what a caller expects and **bypasses the non-raising response mode** entirely. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; **11 operations return `None`**, so `with_raw_response` is the only way to observe their status code; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`), so an unknown wire value passes through as a plain `str` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -211,8 +216,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 3. `DefaultError` — 1 operation (`transaction_search.search_balances`); distinguishing members `name` · `message` · `debug_id` 4. *(none)* — `transaction_search.search_transactions`: no typed arm, so `.error` is always `RawError` 5. So `isinstance(e.error, Error)` matches only 21 of 40 operations. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 1 return type, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which host the `base_url` selects**, because omitting it is silently the default. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 1 return type, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which host the `base_url` selects**, because omitting it is silently the default. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/paypal/skills/python/python-integrate-paypal/SKILL.md b/plugins/paypal/skills/python/python-integrate-paypal/SKILL.md index d9ad9507e..62a0ceeab 100644 --- a/plugins/paypal/skills/python/python-integrate-paypal/SKILL.md +++ b/plugins/paypal/skills/python/python-integrate-paypal/SKILL.md @@ -1,9 +1,9 @@ --- name: "python-integrate-paypal" -description: "MANDATORY FIRST STEP for PayPal Server SDK Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate PayPal Server SDK in Python — adds tracking information for an order, activates the subscription, cancels the subscription, creates a subscription, lists billing plans, lists transactions for a subscription, list all subscriptions for merchant account, suspends the subscription — or when a PayPal Server SDK Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." +description: "MANDATORY FIRST STEP for Paypal Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Paypal in Python — adds tracking information for an order, activates the subscription, cancels the subscription, creates a subscription, lists billing plans, lists transactions for a subscription, list all subscriptions for merchant account, suspends the subscription — or when a Paypal Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." --- -# PayPal Server SDK Python SDK — Integration workflow (lookup layer + contract sheet) +# Paypal Python SDK — Integration workflow (lookup layer + contract sheet) You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. @@ -11,24 +11,24 @@ You do the SDK lookups yourself: `python-getting-started` is your **lookup layer - **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import pay_pal_server_sdk, pathlib; print(pathlib.Path(pay_pal_server_sdk.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import paypal, pathlib; print(pathlib.Path(paypal.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) - **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. -**Scope guard:** the APIMatic-generated PayPal Server SDK **Python SDK** (import root `pay_pal_server_sdk`, distribution `pay-pal-server-sdk`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. +**Scope guard:** the APIMatic-generated Paypal **Python SDK** (import root `paypal`, distribution `paypal`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. ## Workflow -**If the user opens with a reported SDK error or unexpected PayPal Server SDK behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: +**If the user opens with a reported SDK error or unexpected Paypal behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/pay-pal-server-sdk-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/paypal-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `pay-pal-server-sdk` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; +- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `paypal` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; - a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; - credentials/environment verification (per the task's secret-handling rules) — including which host `base_url` will select, since omitting it is silent; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; @@ -36,9 +36,24 @@ Your FIRST action is to load `python-getting-started` and work through the user' Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. -**HARD GATE — no project-file creation or edits until:** `pay-pal-server-sdk-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. +**HARD GATE — no project-file creation or edits until:** `paypal-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. -**What the gate does not cover:** creating or activating a virtual environment, installing `pay-pal-server-sdk` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. +**What the gate does not cover:** creating or activating a virtual environment, installing `paypal` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. ### Step 1c — Required reading (do this before you write any code) @@ -50,30 +65,31 @@ These are API-agnostic usage skills; loading them is not a substitute for the lo Before implementing, check the plan's **Assumptions & Blockers** section: -- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `pay-pal-server-sdk-plan.md` in place. +- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `paypal-plan.md` in place. - Minor assumptions only → proceed. Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. ### Step 2 — Implement from the contract sheet -1. Read `pay-pal-server-sdk-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. +1. Read `paypal-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. 2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). 3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. 4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `pay_pal_server_sdk.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `paypal.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. ### Step 3 — Answering pure questions -A standalone PayPal Server SDK question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. +A standalone Paypal question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. ## Anti-patterns — never do these - **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. -- **Never write a PayPal Server SDK/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Don't web-search PayPal Server SDK topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public PayPal Server SDK docs describe the REST API, not this SDK's generated surface, and the two disagree on names. +- **Never write a Paypal/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** +- **Don't web-search Paypal topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Paypal docs describe the REST API, not this SDK's generated surface, and the two disagree on names. - **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. - **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. diff --git a/plugins/paypal/skills/python/python-models/SKILL.md b/plugins/paypal/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/paypal/skills/python/python-models/SKILL.md +++ b/plugins/paypal/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/paypal/skills/python/python-models/reference.md b/plugins/paypal/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/paypal/skills/python/python-models/reference.md +++ b/plugins/paypal/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/paypal/skills/python/python-testing/SKILL.md b/plugins/paypal/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/paypal/skills/python/python-testing/SKILL.md +++ b/plugins/paypal/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/paypal/skills/typescript/typescript-authentication/SKILL.md b/plugins/paypal/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/paypal/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/paypal/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/paypal/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/paypal/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/paypal/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/paypal/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/paypal/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/paypal/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/paypal/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/paypal/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/paypal/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/paypal/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/paypal/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/paypal/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/paypal/skills/typescript/typescript-error-handling/SKILL.md b/plugins/paypal/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/paypal/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/paypal/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/paypal/skills/typescript/typescript-getting-started/SKILL.md b/plugins/paypal/skills/typescript/typescript-getting-started/SKILL.md index e83305c07..1f9e9c29b 100644 --- a/plugins/paypal/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/paypal/skills/typescript/typescript-getting-started/SKILL.md @@ -1,11 +1,11 @@ --- name: "typescript-getting-started" -description: "PayPal Server SDK TypeScript SDK identity and lookup layer (TypeScript/JavaScript only) — install, the single import specifier `pay-pal-server-sdk`, the server environments and the base-URL knob, the auth pattern, the SDK map that ships inside the installed package (`sdk-map.md` + `map/operations/`) and how to traverse it, and the file table naming the one source file owning each fact the map leaves to the source. Load this before answering any PayPal Server SDK TypeScript SDK contract question or writing any SDK code." +description: "Paypal TypeScript SDK identity and lookup layer (TypeScript/JavaScript only) — install, the single import specifier `paypal`, the server environments and the base-URL knob, the auth pattern, the SDK map that ships inside the installed package (`sdk-map.md` + `map/operations/`) and how to traverse it, and the file table naming the one source file owning each fact the map leaves to the source. Load this before answering any Paypal TypeScript SDK contract question or writing any SDK code." --- -# Getting started with the PayPal Server SDK TypeScript SDK +# Getting started with the Paypal TypeScript SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing PayPal Server SDK TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-paypal` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Paypal TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-paypal` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated TypeScript SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `typescript-client-initialization`, `typescript-authentication`, `typescript-calling-endpoints`, `typescript-models`, `typescript-error-handling`, `typescript-configuration-resilience` and `typescript-testing`. @@ -13,22 +13,24 @@ This is the **SDK-specific** entry point. For general patterns that apply to any ## SDK identity -Verified against `package.json` and `sdk-map.md` of the generated package at version `2.29`. **Re-verify after a version bump** — this page is a snapshot, not a live read. +Verified against `package.json` and `sdk-map.md` of the generated package at version `2.29.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. | Fact | Value | | --- | --- | -| API | PayPal Server SDK | -| Package name (what you install, and what you import) | `pay-pal-server-sdk` — **not on npm**; built from source (see *Install*) | -| Import specifier | `pay-pal-server-sdk` — the package root is the **only** entry; deep imports do not resolve | -| Version | `2.29` (API spec version `2.29`) | -| Client class | `PayPalServerSdkClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new PayPalServerSdkClient(clientOptions: Partial = {})` — **every** field is optional, so `new PayPalServerSdkClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `oauth2` · `oauth2Strategy`. `timeout` defaults to `60_000` ms | +| API | Paypal | +| Package name (what you install, and what you import) | `paypal` — **not on npm**; built from source (see *Install*) | +| Import specifier | `paypal` — the package root is the **only** entry; deep imports do not resolve | +| Source repository | https://github.com/context-plugins/paypal-typescript-sdk (branch `main` — the ref this map documents) | +| Version | `2.29.0` (API spec version `2.29`) | +| Client class | `PaypalClient` (`src/client.ts`) — one class, no sync/async split | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new PaypalClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new PaypalClient()` compiles. Fields: `serverOptions` · `retry` · `fetch` · `oauth2` · `oauth2Strategy`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **OAuth 2 client credentials** — set `ClientOptions.oauth2` | -| Environments | 1 environment (`ServerEnvironment.Sandbox` *(default)*) × 1 server group | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`), defaulting to `https://api-m.sandbox.paypal.com` | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Environments | no selector — one environment, so none is emitted; 1 server group | +| Base-URL config | `serverOptions.baseUrl` (`src/servers.ts`), defaulting to `https://api-m.sandbox.paypal.com` | +| Log environment variable | `PAYPAL_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Surface | 40 operations · 5 resources · 284 models · 87 open enums · 40 per-operation error subclasses | @@ -37,49 +39,47 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/paypal-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 371 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, 371 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { PayPalServerSdkClient, ServerEnvironment, ResponseError, PayPalServerSdkError } from "pay-pal-server-sdk"; -import type { ClientOptions, AvsCode } from "pay-pal-server-sdk"; +import { PaypalClient, ApiError, PaypalError } from "paypal"; +import type { ClientOptions, AvsCode } from "paypal"; ``` Things the specifier alone will not tell you: -- **Deep imports do not resolve.** The `exports` map exposes `.` and `./package.json` and nothing else, so `pay-pal-server-sdk/models/…` fails (`TS2307`) even though the file exists in the shipped `src/`. Every `Source` path on the SDK map is where to **read** a shape, never what to import. -- **⚠ The SDK exports a model type literally named `Error`** (`src/models/error.ts`, schema `errorSchema`). Import it unaliased and it **shadows the global `Error`** for the rest of the file. Alias it: `import type { Error as SdkError } from "pay-pal-server-sdk"`. -- **From CommonJS**, the typed spelling is `import sdk = require("pay-pal-server-sdk")`. A plain `require` destructure runs but yields `any`. +- **Deep imports do not resolve.** The `exports` map exposes `.` and `./package.json` and nothing else, so `paypal/models/…` fails (`TS2307`) even though the file exists in the shipped `src/`. Every `Source` path on the SDK map is where to **read** a shape, never what to import. +- **⚠ The SDK exports a model type literally named `Error`** (`src/models/error.ts`, schema `errorSchema`). Import it unaliased and it **shadows the global `Error`** for the rest of the file. Alias it: `import type { Error as SdkError } from "paypal"`. +- **From CommonJS**, the typed spelling is `import sdk = require("paypal")`. A plain `require` destructure runs but yields `any`. - **`instanceof` is reliable within one dialect.** A process that loads both (`import` in one file, `require` in another) gets two independent copies of every error class, and `instanceof` across that boundary is `false` — narrow on `err.kind` / `err.payload.kind` / `err.name` there. Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, every model type) must be imported with `import type`. Under `exactOptionalPropertyTypes`, **omit or spread** an absent optional field rather than assigning `undefined` to it. -## Environments +## Hosts -`ClientOptions.serverEnvironment` selects one environment for the whole client (`src/servers.ts`). `ServerEnvironment` is a `const` object with a derived union type — not a TypeScript `enum` — and unlike the model enums it is **closed**, so only its declared members are assignable. +This API declares one environment, so the SDK has **no environment selector** — there is no `ServerEnvironment` type and no field to set (`src/servers.ts`). The host below is the only one the SDK knows, and the override in the last column is the only way to reach another. -| Group | Environment | Base URL | Override at | -| --- | --- | --- | --- | -| `default` | `sandbox` *(default)* | `https://api-m.sandbox.paypal.com` | `serverOptions.default.sandbox.baseUrl` | +| Group | Base URL | Override at | +| --- | --- | --- | +| `default` | `https://api-m.sandbox.paypal.com` | `serverOptions.baseUrl` | Consequences to state on every contract sheet that touches configuration: -- Constructing the client with no options selects **`ServerEnvironment.Sandbox`**, silently. -- ⚠ **This SDK declares exactly one environment**, so reaching any other host is a `serverOptions` override, not an environment value — `serverOptions: { default: { sandbox: { baseUrl: "…" } } }`. Note the key is still `sandbox`: the environment name and the host it points at are now decoupled, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the deployment's intent explicit where the client is built, and verify it. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- ⚠ **This SDK declares exactly one environment**, so it emits no `ServerEnvironment` at all and reaching any other host is a `serverOptions` base-URL override — `serverOptions: { baseUrl: "…" }`. Nothing in the options then records **which** deployment this is, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the intent explicit where the client is built, and verify it. +- An override merges with the built-in default **key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. ## Auth pattern (1 scheme) @@ -90,7 +90,7 @@ Authentication is **per operation**: every operation declares the requirement it | `oauth2` | OAuth 2 client credentials | `Authorization: Bearer ` | ```ts -const client = new PayPalServerSdkClient({ +const client = new PaypalClient({ oauth2: { clientId: process.env.CLIENT_ID!, clientSecret: process.env.CLIENT_SECRET! }, }); ``` @@ -101,9 +101,9 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. -**OAuth 2 fetches and caches its own token.** The token request goes through **the same client** — same `timeout`, same `fetch` — sends a form-urlencoded body, and decodes the response against a schema rather than casting it. A token is cached until shortly before it expires; a response with no `expires_in` is treated as never expiring (RFC 6749 §5.1); concurrent callers share one in-flight fetch. **A refused token endpoint rejects with `AuthError`**, wrapping the underlying `ResponseError` as `cause` — so a bad secret never looks like the business call failing, and `AuthError` is *not* a `ResponseError`. +**OAuth 2 fetches and caches its own token.** The token request goes through **the same client** — same `fetch`, same `retry` policy — sends a form-urlencoded body, and decodes the response against a schema rather than casting it. A token is cached until shortly before it expires; a response with no `expires_in` is treated as never expiring (RFC 6749 §5.1); concurrent callers share one in-flight fetch. **A refused token endpoint rejects with `AuthError`**, wrapping the underlying `ApiError` as `cause` — so a bad secret never looks like the business call failing, and `AuthError` is *not* an `ApiError`. The token request is itself a `POST` with its own timeout, retried only when `httpMethodsToRetry` names `POST`. | Flow | Token endpoint | Client credentials travel | | --- | --- | --- | @@ -127,7 +127,7 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | `client.transactionSearch` | `TransactionSearch` | 2 | `searchBalances` · `searchTransactions` | | `client.subscriptions` | `Subscriptions` | 17 | `activateBillingPlan` · `activateSubscription` · `cancelSubscription` · `captureSubscription` · `createBillingPlan` · `createSubscription` · `deactivateBillingPlan` · `getBillingPlan` · `getSubscription` · `listBillingPlans` · `listSubscriptionTransactions` · `listSubscriptions` · `patchBillingPlan` · `patchSubscription` · `reviseSubscription` · `suspendSubscription` · `updateBillingPlanPricingSchemes` | -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** 5 operations take `RequestParams` instead: `confirmOrder`, `activateSubscription`, `cancelSubscription`, `captureSubscription`, `createSubscription`. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -135,20 +135,20 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: ```bash -node -e "console.log(require.resolve('pay-pal-server-sdk/package.json'))" +node -e "console.log(require.resolve('paypal/package.json'))" ``` -Failing that it is at `node_modules/pay-pal-server-sdk/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/paypal/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. -Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/pay-pal-server-sdk/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. +Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/paypal/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -156,9 +156,9 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. -The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/pay-pal-server-sdk/`: +The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/paypal/`: | Question | File | | --- | --- | @@ -166,13 +166,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -186,30 +188,38 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `PAYPAL_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new PayPalServerSdkClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters and the OAuth 2 token cache live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) -2. **Authentication** — load **typescript-authentication** before you set credentials. The scheme is `oauth2` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; the token is fetched lazily and cached on the client; a **failed token fetch** raises `AuthError`, which is not a `ResponseError` and **bypasses `.asApiResult()` entirely**; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new PaypalClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters and the OAuth 2 token cache live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) +2. **Authentication** — load **typescript-authentication** before you set credentials. The scheme is `oauth2` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; the token is fetched lazily and cached on the client; a **failed token fetch** raises `AuthError`, which is not an `ApiError` and **bypasses `.asApiResult()` entirely**; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **11 operations resolve to `undefined`**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `PayPalServerSdkError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `PaypalError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `PAYPAL_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK -Beyond the usual signatures and model members, a contract sheet for the PayPal Server SDK TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. +Beyond the usual signatures and model members, a contract sheet for the Paypal TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. -1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment, `ServerEnvironment.Sandbox`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. +1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment and emits no `ServerEnvironment`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. 2. **11 operations resolve to `undefined`** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet — 5 operations take `RequestParams`, not `Request`. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 40 of 40 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. **That `Error` imported from this package is a model type, not the global of that name** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `PayPalServerSdkError` for the same reason. -10. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 40 of 40 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. **That `Error` imported from this package is a model type, not the global of that name** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `PaypalError` for the same reason. +12. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/paypal/skills/typescript/typescript-integrate-paypal/SKILL.md b/plugins/paypal/skills/typescript/typescript-integrate-paypal/SKILL.md index d8e5b0520..69afcb42e 100644 --- a/plugins/paypal/skills/typescript/typescript-integrate-paypal/SKILL.md +++ b/plugins/paypal/skills/typescript/typescript-integrate-paypal/SKILL.md @@ -1,85 +1,161 @@ --- name: "typescript-integrate-paypal" -description: "MANDATORY FIRST STEP for PayPal Server SDK TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate PayPal Server SDK in TypeScript/Node — authorize order, capture order, confirm order, create order, create order tracking, get order, or when the PayPal Server SDK TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `pay-pal-server-sdk` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (pay-pal-server-sdk-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares only ServerEnvironment.Sandbox, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." +description: "MANDATORY FIRST STEP for Paypal TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Paypal in TypeScript/Node — authorize order, capture order, confirm order, create order, create order tracking, get order, or when the Paypal TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `paypal` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (paypal-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares one environment and no ServerEnvironment, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." --- -# PayPal Server SDK TypeScript SDK — Integration workflow (lookup layer + contract sheet) +# Paypal TypeScript SDK — Integration workflow (lookup layer + contract sheet) You do the SDK lookups yourself: `typescript-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source files its table names) before writing code. The `typescript-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. ## Your lookup layer - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. -- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/pay-pal-server-sdk/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('pay-pal-server-sdk/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/paypal/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('paypal/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. -**Scope guard:** the APIMatic-generated PayPal Server SDK **TypeScript SDK** (package and import specifier `pay-pal-server-sdk`, client `PayPalServerSdkClient`) in **TypeScript or JavaScript projects only**. Unrelated API, or any language other than TypeScript/JavaScript — do nothing; this skill does not apply. +**Scope guard:** the APIMatic-generated Paypal **TypeScript SDK** (package and import specifier `paypal`, client `PaypalClient`) in **TypeScript or JavaScript projects only**. Unrelated API, or any language other than TypeScript/JavaScript — do nothing; this skill does not apply. ## Workflow -**If the user opens with a reported SDK error, a compile failure, or unexpected PayPal Server SDK behaviour** (not new feature work), skip the plan-first flow: load `typescript-getting-started` and `typescript-error-handling`, look the failing symbol up on the map page or in the file the table names, and fix from what you find. Otherwise, for implementation work: +**If the user opens with a reported SDK error, a compile failure, or unexpected Paypal behaviour** (not new feature work), skip the plan-first flow: load `typescript-getting-started` and `typescript-error-handling`, look the failing symbol up on the map page or in the file the table names, and fix from what you find. Otherwise, for implementation work: ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/pay-pal-server-sdk-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/paypal-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, **module format — ESM or CJS, which changes how the SDK is imported and whether `instanceof` on its error classes is trustworthy**, and the runtime: Node, a bundled browser app, or an edge runtime) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- **establish the toolchain before you need it**: the package manager, whether `pay-pal-server-sdk` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; +- **establish the toolchain before you need it**: the package manager, whether `paypal` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; - a baseline run of the project's checks (`tsc --noEmit`, the test runner, the linter — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment (`ServerEnvironment.Sandbox`), so **any other host is a `serverOptions` base-URL override rather than an environment value**; +- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment, so it emits no `ServerEnvironment` at all and **any other host is a `serverOptions` base-URL override rather than an environment value**; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your credential may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; - setting up your task tracking. Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. -**HARD GATE — no project-file creation or edits until:** `pay-pal-server-sdk-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. +**HARD GATE — no project-file creation or edits until:** `paypal-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. -**What the gate does not cover:** installing `pay-pal-server-sdk` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +**What the gate does not cover:** installing `paypal` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. + +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. Before implementing, check the plan's **Assumptions & Blockers** section: -- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `pay-pal-server-sdk-plan.md` in place. +- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `paypal-plan.md` in place. - Minor assumptions only → proceed. Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. ### Step 2 — Implement from the contract sheet -1. Read `pay-pal-server-sdk-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment — `ServerEnvironment.Sandbox` — so a different host is a `serverOptions` override under a key still named after `sandbox`: the environment name and the host it points at are decoupled, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. +1. Read `paypal-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. +2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment and emits no selector for it, so a different host is a `serverOptions` base-URL override. Nothing in the options then records **which** deployment this is, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. 3. **Build the client once and hold it.** Resources are memoized lazy getters on it and the OAuth 2 token cache lives on it, so rebuilding one per request throws that state away. There is no `close()` — it owns a `fetch`, not a pool. 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from the **11 operations that resolve to `undefined`** (every operation whose success declares no body — the map's **Returns** bullet names them). `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `PaypalError` family, told apart by `err.kind`. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `PAYPAL_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions -A standalone PayPal Server SDK question with no code change: look it up in `typescript-getting-started` (and the map page or file its table names), then give the grounded answer, citing where it came from. When several questions arrive batched, answer them in one pass. Never answer from memory, even for "easy" questions. +A standalone Paypal question with no code change: look it up in `typescript-getting-started` (and the map page or file its table names), then give the grounded answer, citing where it came from. When several questions arrive batched, answer them in one pass. Never answer from memory, even for "easy" questions. ## Anti-patterns — never do these - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. -- **Never write any PayPal Server SDK or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. -- **Don't web-search PayPal Server SDK topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public PayPal Server SDK docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. +- **Never write any Paypal or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Don't web-search Paypal topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Paypal docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. -- **Don't deep-import from the package** (`pay-pal-server-sdk/models/…`). The package root is the only entry and deep specifiers do not resolve; the map's `Source` paths are for reading, not importing. -- **Don't import `Error` unaliased.** This SDK exports a type named after a TypeScript/DOM global, so importing it plain shadows the global for the whole file. Alias on import from `pay-pal-server-sdk`. +- **Don't deep-import from the package** (`paypal/models/…`). The package root is the only entry and deep specifiers do not resolve; the map's `Source` paths are for reading, not importing. +- **Don't import `Error` unaliased.** This SDK exports a type named after a TypeScript/DOM global, so importing it plain shadows the global for the whole file. Alias on import from `paypal`. - **Don't vendor the SDK's `src/` into the project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly** to make a lookup possible. Install it properly (see `typescript-getting-started`); a clone-based shortcut breaks every import the moment the clone is deleted, and it does not put the map where the lookups expect it. - **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. diff --git a/plugins/paypal/skills/typescript/typescript-models/SKILL.md b/plugins/paypal/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/paypal/skills/typescript/typescript-models/SKILL.md +++ b/plugins/paypal/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/paypal/skills/typescript/typescript-models/reference.md b/plugins/paypal/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/paypal/skills/typescript/typescript-models/reference.md +++ b/plugins/paypal/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/paypal/skills/typescript/typescript-testing/SKILL.md b/plugins/paypal/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/paypal/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/paypal/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/plaid/.claude-plugin/plugin.json b/plugins/plaid/.claude-plugin/plugin.json index b5ce97276..b256fd4a1 100644 --- a/plugins/plaid/.claude-plugin/plugin.json +++ b/plugins/plaid/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "plaid", "displayName": "The Plaid API", - "version": "0.1.2", + "version": "0.2.0", "description": "Integrate and consume the APIMatic-generated The Plaid API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/plaid/.cursor-plugin/plugin.json b/plugins/plaid/.cursor-plugin/plugin.json index b5ce97276..b256fd4a1 100644 --- a/plugins/plaid/.cursor-plugin/plugin.json +++ b/plugins/plaid/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "plaid", "displayName": "The Plaid API", - "version": "0.1.2", + "version": "0.2.0", "description": "Integrate and consume the APIMatic-generated The Plaid API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/plaid/plugin.json b/plugins/plaid/plugin.json index b5ce97276..b256fd4a1 100644 --- a/plugins/plaid/plugin.json +++ b/plugins/plaid/plugin.json @@ -1,7 +1,7 @@ { "name": "plaid", "displayName": "The Plaid API", - "version": "0.1.2", + "version": "0.2.0", "description": "Integrate and consume the APIMatic-generated The Plaid API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/plaid/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/plaid/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..5a594f7b9 100644 --- a/plugins/plaid/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/plaid/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,67 +1,55 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the The Plaid API .NET SDK in C# — this API authenticates with an API key in a header and OAuth 2.0 client credentials, and this skill gives the credential each scheme takes, the ThePlaidApiClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the The Plaid API .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with an API key in a header and OAuth 2.0 client credentials. APIMatic surfaces each scheme as a **nullable credentials property** on `ThePlaidApiClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. +| Scheme | Property on `ThePlaidApiClientOptions` | Credential | +| --- | --- | --- | +| API key — `PLAID-CLIENT-ID` header | `ClientId` | `string` | +| API key — `PLAID-SECRET` header | `Secret` | `string` | +| API key — `Plaid-Version` header | `PlaidVersion` | `string` | +| OAuth 2.0 client credentials | `Oauth2` | `OAuth2ClientCredentials` | -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) +**That table is the entire authentication surface of this SDK.** `ThePlaidApi.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). +## API key (header) — `ClientId` -## Basic auth +The key travels in the `PLAID-CLIENT-ID` request header. The header name is fixed by the generated scheme — you supply only the value: ```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; +options.ClientId = "API_KEY"; ``` -Sends `Authorization: Basic base64(username:password)`. +## API key (header) — `Secret` -## Bearer token - -Set the configured token property on the options class to your access-token string: +The key travels in the `PLAID-SECRET` request header. The header name is fixed by the generated scheme — you supply only the value: ```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; +options.Secret = "API_KEY"; ``` -Sends `Authorization: Bearer ACCESS_TOKEN`. - -## API key (header, query, or cookie) +## API key (header) — `PlaidVersion` -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: +The key travels in the `Plaid-Version` request header. The header name is fixed by the generated scheme — you supply only the value: ```csharp -options.{ApiKeyProperty} = "API_KEY"; +options.PlaidVersion = "API_KEY"; ``` ## OAuth 2.0 — client credentials (machine-to-machine) +**API Provider Notes:** + +> The Plaid API supports client credentials, authorization code, and custom delegation flows. + ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; +using ThePlaidApi.Core.Authentication.OAuth2.ClientCredentials; -options.{OAuthProperty} = new OAuth2ClientCredentials +options.Oauth2 = new OAuth2ClientCredentials { ClientId = "...", ClientSecret = "...", @@ -69,177 +57,87 @@ options.{OAuthProperty} = new OAuth2ClientCredentials }; ``` -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. +The SDK fetches the token from `https://api.plaid.com/oauth2/apiv2/token` and caches it, acquiring a fresh one when it expires; on a `401` it invalidates the cached token and re-acquires. The token request carries the client id and secret as HTTP Basic credentials, so `ClientSecret` is required. -## OAuth 2.0 — authorization code (3-legged, with PKCE) +## Token caching & refresh -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; +- Tokens are cached in-memory, **per client instance**, and reused until **30s** before expiry. +- **Nothing here refreshes.** The grants above are not wired to `IOAuth2RefreshableTokenStrategy` — when a token expires the whole grant re-runs. A `refresh_token` in the response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do not infer refresh behaviour from what the provider returns. +- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is **not** retried. +- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of a fresh token. -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials -{ - ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE - RedirectUri = "https://app.example.com/callback", - Scope = "...", // optional - State = "...", // optional CSRF token - Pkce = PkceMethod.S256, // default; RFC 7636 - PromptForAuthorizationCode = async (authorizationUrl, ct) => - { - // Open/redirect the browser to authorizationUrl, then return the - // authorization code your redirect endpoint received. - return await GetCodeFromUserAsync(authorizationUrl, ct); - } -}; -``` +⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** `expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to "not expired" when it is absent — so the first token is cached for the life of the client and the only thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. +### Supplying your own token source -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: +The token strategy is a public extension point: `ThePlaidApiClientOptions` exposes a strategy property beside each credentials property, and the generated client falls back to the built-in strategy only when you leave it null. The strategy is used only when the credentials property beside it is also set, and `GetToken` receives that object. -| PKCE | token request built | no secret ⇒ | +| Grant | Strategy property on `ThePlaidApiClientOptions` | Type | | --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | - -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. - -## OAuth 2.0 — resource owner password +| Client credentials | `Oauth2TokenStrategy` | `IOAuth2TokenStrategy` | ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; - -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; +options.Oauth2 = new OAuth2ClientCredentials { /* its required members */ }; +options.Oauth2TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) ``` -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. +Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test double. The per-client in-memory cache above still wraps whatever you return. -### Supplying your own token source +## Combining the schemes -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. +This SDK generates more than one credentials property, and its operations compose them. You configure the composition by setting the relevant properties on `ThePlaidApiClientOptions` — the generated client wires the rest. -```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) -``` +**AND (`AuthSchemeAll`) — every scheme in the group is applied to the request.** Leave one of them unconfigured and the request still goes out, just without that credential; the provider decides what to make of a half-authenticated call. -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. +**OR (`AuthSchemeAny`) — alternatives, tried in order.** Schemes with **no credentials configured are skipped, not tried**; the first configured scheme that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails. Configure exactly the one you intend to use — an extra credential set "just in case" changes which scheme authenticates the call. **Which one that is, is not a preference:** each scheme's **API Provider Notes** above are where the provider states when its scheme applies. -## Combined / multiple schemes +## Operations that need no credentials -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: - -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). - -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. - -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +Some operations on this API are public: the generated endpoint uses `NoneAuthScheme` and sends no credential, whatever you set on `ThePlaidApiClientOptions`. You still construct the same client — there is no separate unauthenticated client — and the credentials you configured are simply not applied to those calls. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `ThePlaidApiClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("ThePlaidApiClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.ClientId)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "ThePlaidApiClient:ClientId is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"ThePlaidApiClient:ClientId is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. + +Check every value **the scheme you chose** needs. The rows below are not one checklist: the schemes above are alternatives, so a row belonging to a scheme you are not using needs no value at all, and demanding one at startup blocks a boot that would have worked. Validate the rows whose property you set, and no others: -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +| Value to validate | Feeds `ThePlaidApiClientOptions` property | +| --- | --- | +| `ClientId` | `ClientId` | +| `Secret` | `Secret` | +| `PlaidVersion` | `PlaidVersion` | +| `ClientId` | `Oauth2` | +| `ClientSecret` | `Oauth2` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddThePlaidApiClient(options => ...)` callback when registering via DI. `ThePlaidApiClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddThePlaidApiClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/plaid/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/plaid/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/plaid/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/plaid/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/plaid/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/plaid/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/plaid/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/plaid/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/plaid/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/plaid/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/plaid/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/plaid/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/plaid/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/plaid/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/plaid/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/plaid/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/plaid/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/plaid/skills/dotnet/dotnet-getting-started/SKILL.md index dce4b301c..ec152f2aa 100644 --- a/plugins/plaid/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/plaid/skills/dotnet/dotnet-getting-started/SKILL.md @@ -11,7 +11,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity @@ -26,7 +26,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any | Auth | **API key** in the `PLAID-CLIENT-ID` header — set `ClientId`
**API key** in the `PLAID-SECRET` header — set `Secret`
**API key** in the `Plaid-Version` header — set `PlaidVersion`
**OAuth 2.0** client credentials — set `Oauth2` | | Environments | `ServerEnvironment.Production` *(default)* → `Default` `https://production.plaid.com`, `AccessTokenServer` `https://api.plaid.com/oauth2/apiv2`
`ServerEnvironment.Environment2` → `Default` `https://sandbox.plaid.com`, `AccessTokenServer` `https://api.plaid.com/oauth2/apiv2` | | Log environment variable | `THEPLAIDAPICLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-the-plaid-api` makes that binding); the **Integration workflow** below says which governs which step. @@ -44,22 +44,24 @@ Models, enums, union types and the generated error types each sit in their own c ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call The Plaid API, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/ThePlaidApi/Models/`, `src/ThePlaidApi/Errors/` and `src/ThePlaidApi/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/ThePlaidApi/Models/`, enums `src/ThePlaidApi/Models/Enums/`, typed errors `src/ThePlaidApi/Errors/`, request records `src/ThePlaidApi/Requests/`, or `src/ThePlaidApi/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: @@ -69,7 +71,7 @@ Keep lookups cheap — the rules that keep a session's context small: ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/the-plaid-api-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/the-plaid-api-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. **Clone once, up front**, into a fresh timestamped folder under `/the-plaid-api-sdk-src/`, and reuse that folder for the rest of your session: @@ -87,30 +89,31 @@ git clone --filter=blob:none --branch main https://github.com/context-plugins/pl # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/the-plaid-api-sdk-src/`, **never** in the project repo, and its path never goes into `the-plaid-api-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `the-plaid-api-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `ThePlaidApiClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/ThePlaidApi/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/ThePlaidApi/Models/` (+ `src/ThePlaidApi/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/ThePlaidApi/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/ThePlaidApi/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/ThePlaidApi/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/ThePlaidApi/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/ThePlaidApi/Servers/`, `src/ThePlaidApi/ThePlaidApiClient.cs`, `src/ThePlaidApi/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and The Plaid API may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything @@ -126,7 +129,7 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/ThePlaidApi/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step @@ -134,9 +137,9 @@ You loaded every companion the sheet named before starting (per `dotnet-integrat 1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new ThePlaidApiClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) 2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `ThePlaidApiClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/plaid/skills/dotnet/dotnet-integrate-the-plaid-api/SKILL.md b/plugins/plaid/skills/dotnet/dotnet-integrate-the-plaid-api/SKILL.md index b1b112bad..b2d08e23f 100644 --- a/plugins/plaid/skills/dotnet/dotnet-integrate-the-plaid-api/SKILL.md +++ b/plugins/plaid/skills/dotnet/dotnet-integrate-the-plaid-api/SKILL.md @@ -11,7 +11,7 @@ You are the The Plaid API .NET SDK specialist for this task — one agent for ev ## Grounding rules -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `the-plaid-api-plan.md` — the plan must stay portable. +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `the-plaid-api-plan.md` never carries its path — the plan must stay portable. - **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. - **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. - **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. @@ -29,7 +29,7 @@ Your FIRST deliverable is `the-plaid-api-plan.md` at `/the-pl #### How to ground (map-first, one pass) 1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. 3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. 4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. 5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. @@ -38,7 +38,7 @@ Your FIRST deliverable is `the-plaid-api-plan.md` at `/the-pl These need no SDK knowledge and touch no project file, so do them before or alongside the map work: -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); - the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; - locating the SDK reference and its version in the project; @@ -54,10 +54,23 @@ Never use this prerequisite work as a "head start" on implementation: **creating #### The `the-plaid-api-plan.md` format (keep it tight — tables, not prose) 1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + 3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. 6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. 7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. @@ -67,12 +80,46 @@ Never use this prerequisite work as a "head start" on implementation: **creating | 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | | 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | | 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | | 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | | 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | | 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. @@ -95,14 +142,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. 3. After every change: `dotnet build`; fix non-SDK errors yourself. 4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `ThePlaidApi.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. ### Step 4 — Fixing SDK errors (map-first, in place) A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `ThePlaidApi.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/ThePlaidApi/Models/{Type}.cs`, `src/ThePlaidApi/Models/Enums/{Type}.cs`, `src/ThePlaidApi/Errors/{Type}.cs`, `src/ThePlaidApi/Requests/{Type}.cs`, or `src/ThePlaidApi/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. 3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". 4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). 5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. @@ -120,6 +168,6 @@ A standalone The Plaid API question with no code change: load `dotnet-getting-st - **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. - **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. - **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search The Plaid API for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `ThePlaidApi` is what the generated source declares, not what public docs describe. +- **Do not web-search The Plaid API for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `ThePlaidApi` is what the generated source declares, not what public docs describe. - **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. diff --git a/plugins/plaid/skills/dotnet/dotnet-models/SKILL.md b/plugins/plaid/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/plaid/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/plaid/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/plaid/skills/dotnet/dotnet-models/reference.md b/plugins/plaid/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/plaid/skills/dotnet/dotnet-models/reference.md +++ b/plugins/plaid/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/plaid/skills/dotnet/dotnet-testing/SKILL.md b/plugins/plaid/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/plaid/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/plaid/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/plaid/skills/python/python-authentication/SKILL.md b/plugins/plaid/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/plaid/skills/python/python-authentication/SKILL.md +++ b/plugins/plaid/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/plaid/skills/python/python-calling-endpoints/SKILL.md b/plugins/plaid/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/plaid/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/plaid/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/plaid/skills/python/python-client-initialization/SKILL.md b/plugins/plaid/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/plaid/skills/python/python-client-initialization/SKILL.md +++ b/plugins/plaid/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/plaid/skills/python/python-configuration-resilience/SKILL.md b/plugins/plaid/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/plaid/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/plaid/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/plaid/skills/python/python-error-handling/SKILL.md b/plugins/plaid/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/plaid/skills/python/python-error-handling/SKILL.md +++ b/plugins/plaid/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/plaid/skills/python/python-event-streams/SKILL.md b/plugins/plaid/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/plaid/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/plaid/skills/python/python-file-handling/SKILL.md b/plugins/plaid/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/plaid/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/plaid/skills/python/python-getting-started/SKILL.md b/plugins/plaid/skills/python/python-getting-started/SKILL.md index c2093ce65..c676764ad 100644 --- a/plugins/plaid/skills/python/python-getting-started/SKILL.md +++ b/plugins/plaid/skills/python/python-getting-started/SKILL.md @@ -25,12 +25,12 @@ Verified against `the_plaid_api/` and `pyproject.toml` of the generated package | Version | `0.1.0` | | Sync client class | `ThePlaidApiClient` (alias `Client`) | | Async client class | `AsyncThePlaidApiClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `environment` · `timeout` (default `30.0`) · `server_config` · `client_id` · `secret` · `plaid_version` · `oauth2` · `oauth2_token_source`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Client construction | **keyword-only**: `environment` · `timeout` (default `30.0`) · `server_config` · `retry_options` · `client_id` · `secret` · `plaid_version` · `oauth2` · `oauth2_token_source`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | **API key** in the `PLAID-CLIENT-ID` header — set `client_id` · **API key** in the `PLAID-SECRET` header — set `secret` · **API key** in the `Plaid-Version` header — set `plaid_version` · **OAuth 2.0** client credentials — set `oauth2` | | Environments | 2 environments (default `"production"`) × 2 named servers, through `server_config` | | Base-URL config | `ServerConfig` (`the_plaid_api/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | | Surface | 335 operations, all directly on the client — no controller groups; the map's single page is `map/operations/client.md` · 1750 models · 0 unions · 354 enums · 1 per-operation error union | @@ -39,13 +39,18 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash pip install "the-plaid-api @ git+https://github.com/context-plugins/plaid-python-sdk.git@main" +python -c "import the_plaid_api, pathlib; print(pathlib.Path(the_plaid_api.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules @@ -146,15 +151,15 @@ See `python-authentication` for the full picture, including what a *failed token The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `the_plaid_api/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `the_plaid_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `the_plaid_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/plaid-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. Every `Source` path on the map is relative to that SDK root, so `the_plaid_api/models/aamvaanalysis.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. @@ -174,7 +179,7 @@ Read the one module that owns the fact **inside the installed package**. Locate python -c "import the_plaid_api, pathlib; print(pathlib.Path(the_plaid_api.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/the_plaid_api` on Windows, `.venv/lib/python3.*/site-packages/the_plaid_api` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/the_plaid_api` on Windows, `.venv/lib/python3.*/site-packages/the_plaid_api` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -182,7 +187,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/th | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -192,7 +197,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/th | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | | An operation's error mapper (status → schema) | `errors/_error.py` | @@ -209,13 +214,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The four schemes are `client_id=`, `secret=`, `plaid_version=` and `oauth2=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; the token is fetched lazily and cached by the client; and a **failed token fetch** raises something other than what a caller expects and **bypasses the non-raising response mode** entirely. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; **11 operations return `None`**, so `with_raw_response` is the only way to observe their status code; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`), so an unknown wire value passes through as a plain `str` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -229,8 +234,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 1. `Error | None` — 1 operation (`sandbox_transactions_create`); distinguishing members *none required* 2. *(none)* — `accounts_balance_get` · `accounts_get` · `application_get` · `asset_report_audit_copy_create` · `asset_report_audit_copy_get` · `asset_report_audit_copy_pdf_get` · `asset_report_audit_copy_remove` · `asset_report_create` · `asset_report_filter` · `asset_report_get` · `asset_report_pdf_get` · `asset_report_refresh` · `asset_report_remove` · `auth_get` · `auth_verify` · `bank_transfer_balance_get` · `bank_transfer_cancel` · `bank_transfer_create` · `bank_transfer_event_list` · `bank_transfer_event_sync` · `bank_transfer_get` · `bank_transfer_list` · `bank_transfer_migrate_account` · `bank_transfer_sweep_get` · `bank_transfer_sweep_list` · `beacon_account_risk_evaluate` · `beacon_duplicate_get` · `beacon_report_create` · `beacon_report_get` · `beacon_report_list` · `beacon_report_syndication_get` · `beacon_report_syndication_list` · `beacon_user_account_insights_get` · `beacon_user_create` · `beacon_user_get` · `beacon_user_history_list` · `beacon_user_review` · `beacon_user_update` · `beta_ewa_report_v1_get` · `beta_partner_customer_v1_create` · `beta_partner_customer_v1_enable` · `beta_partner_customer_v1_get` · `beta_partner_customer_v1_update` · `business_verification_create` · `business_verification_get` · `cashflow_report_get` · `cashflow_report_insights_get` · `cashflow_report_refresh` · `cashflow_report_transactions_get` · `categories_get` · `consent_events_get` · `consumer_report_pdf_get` · `cra_check_report_base_report_get` · `cra_check_report_cashflow_insights_get` · `cra_check_report_create` · `cra_check_report_income_insights_get` · `cra_check_report_lend_score_get` · `cra_check_report_network_insights_get` · `cra_check_report_partner_insights_get` · `cra_check_report_pdf_get` · `cra_check_report_verification_get` · `cra_check_report_verification_pdf_get` · `cra_credit_profile_report_get` · `cra_loans_applications_register` · `cra_loans_register` · `cra_loans_unregister` · `cra_loans_update` · `cra_monitoring_insights_get` · `cra_monitoring_insights_subscribe` · `cra_monitoring_insights_unsubscribe` · `cra_partner_insights_get` · `cra_report_get` · `create_payment_token` · `credit_asset_report_freddie_mac_get` · `credit_audit_copy_token_create` · `credit_audit_copy_token_update` · `credit_bank_employment_get` · `credit_bank_income_get` · `credit_bank_income_pdf_get` · `credit_bank_income_refresh` · `credit_bank_income_webhook_update` · `credit_bank_statements_uploads_get` · `credit_employment_get` · `credit_freddie_mac_reports_get` · `credit_payroll_income_get` · `credit_payroll_income_parsing_config_update` · `credit_payroll_income_precheck` · `credit_payroll_income_refresh` · `credit_payroll_income_risk_signals_get` · `credit_relay_create` · `credit_relay_get` · `credit_relay_pdf_get` · `credit_relay_refresh` · `credit_relay_remove` · `credit_report_audit_copy_remove` · `credit_sessions_get` · `dashboard_user_get` · `dashboard_user_list` · `employers_search` · `employment_verification_get` · `fdx_consents_get` · `fdx_consents_list` · `fdx_consents_revocation_get` · `fdx_consents_revoke` · `fdx_notifications` · `get_recipient` · `get_recipients` · `identity_documents_uploads_get` · `identity_get` · `identity_match` · `identity_refresh` · `identity_verification_autofill_create` · `identity_verification_create` · `identity_verification_get` · `identity_verification_list` · `identity_verification_retry` · `income_verification_create` · `income_verification_documents_download` · `income_verification_paystubs_get` · `income_verification_precheck` · `income_verification_taxforms_get` · `institutions_get` · `institutions_get_by_id` · `institutions_search` · `investments_auth_get` · `investments_holdings_get` · `investments_refresh` · `investments_transactions_get` · `issues_get` · `issues_search` · `issues_subscribe` · `item_access_token_invalidate` · `item_activity_list` · `item_application_list` · `item_application_scopes_update` · `item_application_unlink` · `item_create_public_token` · `item_get` · `item_import` · `item_products_terminate` · `item_public_token_exchange` · `item_remove` · `item_webhook_update` · `liabilities_get` · `link_delivery_create` · `link_delivery_get` · `link_oauth_correlation_id_exchange` · `link_token_create` · `link_token_get` · `network_status_get` · `oauth_introspect` · `oauth_revoke` · `oauth_token` · `partner_customer_create` · `partner_customer_enable` · `partner_customer_get` · `partner_customer_oauth_institutions_get` · `partner_customer_remove` · `payment_initiation_consent_create` · `payment_initiation_consent_get` · `payment_initiation_consent_payment_execute` · `payment_initiation_consent_revoke` · `payment_initiation_payment_create` · `payment_initiation_payment_get` · `payment_initiation_payment_list` · `payment_initiation_payment_reverse` · `payment_initiation_recipient_create` · `payment_initiation_recipient_get` · `payment_initiation_recipient_list` · `payment_profile_create` · `payment_profile_get` · `payment_profile_remove` · `processor_account_get` · `processor_apex_processor_token_create` · `processor_auth_get` · `processor_balance_get` · `processor_bank_transfer_create` · `processor_identity_get` · `processor_identity_match` · `processor_investments_auth_get` · `processor_investments_holdings_get` · `processor_investments_transactions_get` · `processor_liabilities_get` · `processor_signal_decision_report` · `processor_signal_evaluate` · `processor_signal_prepare` · `processor_signal_return_report` · `processor_stripe_bank_account_token_create` · `processor_token_create` · `processor_token_permissions_get` · `processor_token_permissions_set` · `processor_token_webhook_update` · `processor_transactions_get` · `processor_transactions_recurring_get` · `processor_transactions_refresh` · `processor_transactions_sync` · `profile_network_status_get` · `protect_compute` · `protect_event_get` · `protect_event_send` · `protect_report_create` · `protect_user_insights_get` · `sandbox_bank_income_fire_webhook` · `sandbox_bank_transfer_fire_webhook` · `sandbox_bank_transfer_simulate` · `sandbox_cra_cashflow_updates_update` · `sandbox_fdx_consent_seed` · `sandbox_income_fire_webhook` · `sandbox_item_application_seed` · `sandbox_item_fire_webhook` · `sandbox_item_reset_login` · `sandbox_item_set_verification_status` · `sandbox_oauth_select_accounts` · `sandbox_payment_profile_reset_login` · `sandbox_payment_simulate` · `sandbox_processor_token_create` · `sandbox_public_token_create` · `sandbox_transfer_fire_webhook` · `sandbox_transfer_ledger_deposit_simulate` · `sandbox_transfer_ledger_simulate_available` · `sandbox_transfer_ledger_withdraw_simulate` · `sandbox_transfer_refund_simulate` · `sandbox_transfer_repayment_simulate` · `sandbox_transfer_simulate` · `sandbox_transfer_sweep_simulate` · `sandbox_transfer_test_clock_advance` · `sandbox_transfer_test_clock_create` · `sandbox_transfer_test_clock_get` · `sandbox_transfer_test_clock_list` · `sandbox_user_reset_login` · `session_token_create` · `signal_decision_report` · `signal_evaluate` · `signal_prepare` · `signal_return_report` · `signal_schedule` · `statements_download` · `statements_list` · `statements_refresh` · `transactions_enhance` · `transactions_enrich` · `transactions_get` · `transactions_recurring_get` · `transactions_refresh` · `transactions_rules_create` · `transactions_rules_list` · `transactions_rules_remove` · `transactions_sync` · `transactions_user_insights_get` · `transfer_authorization_cancel` · `transfer_authorization_create` · `transfer_balance_get` · `transfer_cancel` · `transfer_capabilities_get` · `transfer_configuration_get` · `transfer_create` · `transfer_diligence_document_upload` · `transfer_diligence_submit` · `transfer_event_list` · `transfer_event_sync` · `transfer_get` · `transfer_intent_create` · `transfer_intent_get` · `transfer_ledger_deposit` · `transfer_ledger_distribute` · `transfer_ledger_event_list` · `transfer_ledger_get` · `transfer_ledger_withdraw` · `transfer_list` · `transfer_metrics_get` · `transfer_migrate_account` · `transfer_originator_create` · `transfer_originator_funding_account_create` · `transfer_originator_funding_account_update` · `transfer_originator_get` · `transfer_originator_list` · `transfer_platform_originator_create` · `transfer_platform_person_create` · `transfer_platform_requirement_submit` · `transfer_questionnaire_create` · `transfer_recurring_cancel` · `transfer_recurring_create` · `transfer_recurring_get` · `transfer_recurring_list` · `transfer_refund_cancel` · `transfer_refund_create` · `transfer_refund_get` · `transfer_repayment_list` · `transfer_repayment_return_list` · `transfer_return_recover` · `transfer_sweep_get` · `transfer_sweep_list` · `user_account_session_event_send` · `user_account_session_get` · `user_create` · `user_financial_data_refresh` · `user_get` · `user_identity_remove` · `user_items_associate` · `user_items_get` · `user_items_remove` · `user_products_terminate` · `user_remove` · `user_third_party_token_create` · `user_third_party_token_remove` · `user_transactions_refresh` · `user_update` · `wallet_create` · `wallet_get` · `wallet_list` · `wallet_transaction_execute` · `wallet_transaction_get` · `wallet_transaction_list` · `watchlist_screening_entity_create` · `watchlist_screening_entity_get` · `watchlist_screening_entity_history_list` · `watchlist_screening_entity_hit_list` · `watchlist_screening_entity_list` · `watchlist_screening_entity_program_get` · `watchlist_screening_entity_program_list` · `watchlist_screening_entity_review_create` · `watchlist_screening_entity_review_list` · `watchlist_screening_entity_update` · `watchlist_screening_individual_create` · `watchlist_screening_individual_get` · `watchlist_screening_individual_history_list` · `watchlist_screening_individual_hit_list` · `watchlist_screening_individual_list` · `watchlist_screening_individual_program_get` · `watchlist_screening_individual_program_list` · `watchlist_screening_individual_review_create` · `watchlist_screening_individual_review_list` · `watchlist_screening_individual_update` · `webhook_verification_key_get`: no typed arm, so `.error` is always `RawError` 3. So `isinstance(e.error, Error | None)` matches only 1 of 335 operations. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 307 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"`. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 307 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"`. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/plaid/skills/python/python-integrate-the-plaid-api/SKILL.md b/plugins/plaid/skills/python/python-integrate-the-plaid-api/SKILL.md index 1a69ee744..e79fb3312 100644 --- a/plugins/plaid/skills/python/python-integrate-the-plaid-api/SKILL.md +++ b/plugins/plaid/skills/python/python-integrate-the-plaid-api/SKILL.md @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `python-getting-started` is your **lookup layer - **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import the_plaid_api, pathlib; print(pathlib.Path(the_plaid_api.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import the_plaid_api, pathlib; print(pathlib.Path(the_plaid_api.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) - **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. @@ -40,6 +40,21 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** creating or activating a virtual environment, installing `the-plaid-api` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. @@ -62,8 +77,9 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. 4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `the_plaid_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `the_plaid_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. ### Step 3 — Answering pure questions diff --git a/plugins/plaid/skills/python/python-models/SKILL.md b/plugins/plaid/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/plaid/skills/python/python-models/SKILL.md +++ b/plugins/plaid/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/plaid/skills/python/python-models/reference.md b/plugins/plaid/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/plaid/skills/python/python-models/reference.md +++ b/plugins/plaid/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/plaid/skills/python/python-testing/SKILL.md b/plugins/plaid/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/plaid/skills/python/python-testing/SKILL.md +++ b/plugins/plaid/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/plaid/skills/typescript/typescript-authentication/SKILL.md b/plugins/plaid/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/plaid/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/plaid/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/plaid/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/plaid/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/plaid/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/plaid/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/plaid/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/plaid/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/plaid/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/plaid/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/plaid/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/plaid/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/plaid/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/plaid/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/plaid/skills/typescript/typescript-error-handling/SKILL.md b/plugins/plaid/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/plaid/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/plaid/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/plaid/skills/typescript/typescript-getting-started/SKILL.md b/plugins/plaid/skills/typescript/typescript-getting-started/SKILL.md index 33ea318a9..870eacda1 100644 --- a/plugins/plaid/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/plaid/skills/typescript/typescript-getting-started/SKILL.md @@ -13,22 +13,24 @@ This is the **SDK-specific** entry point. For general patterns that apply to any ## SDK identity -Verified against `package.json` and `sdk-map.md` of the generated package at version `2020-09-14_1.708.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. +Verified against `package.json` and `sdk-map.md` of the generated package at version `0.1.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. | Fact | Value | | --- | --- | | API | The Plaid API | | Package name (what you install, and what you import) | `the-plaid-api` — **not on npm**; built from source (see *Install*) | | Import specifier | `the-plaid-api` — the package root is the **only** entry; deep imports do not resolve | -| Version | `2020-09-14_1.708.0` (API spec version `2020-09-14_1.708.0`) | +| Source repository | https://github.com/context-plugins/plaid-typescript-sdk (branch `main` — the ref this map documents) | +| Version | `0.1.0` (API spec version `2020-09-14_1.708.0`) | | Client class | `ThePlaidApiClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new ThePlaidApiClient(clientOptions: Partial = {})` — **every** field is optional, so `new ThePlaidApiClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `clientId` · `secret` · `plaidVersion` · `oauth2` · `oauth2Strategy`. `timeout` defaults to `60_000` ms | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new ThePlaidApiClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new ThePlaidApiClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `retry` · `fetch` · `clientId` · `secret` · `plaidVersion` · `oauth2` · `oauth2Strategy`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **API key (header)** — set `ClientOptions.clientId`
**API key (header)** — set `ClientOptions.secret`
**API key (header)** — set `ClientOptions.plaidVersion`
**OAuth 2 client credentials** — set `ClientOptions.oauth2` | | Environments | 2 environments (`ServerEnvironment.Production` *(default)*, `ServerEnvironment.Environment2`) × 2 server groups | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`) | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Base-URL config | `serverOptions..baseUrl` (`src/servers.ts`) | +| Log environment variable | `THE_PLAID_API_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Surface | 335 operations · 0 resources · 1750 models · 354 open enums · 289 per-operation error subclasses | @@ -37,22 +39,22 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/plaid-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 2104 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 2104 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { ThePlaidApiClient, ServerEnvironment, ResponseError, ThePlaidApiError } from "the-plaid-api"; +import { ThePlaidApiClient, ServerEnvironment, ApiError, ThePlaidApiError } from "the-plaid-api"; import type { ClientOptions, AamvaAnalysis } from "the-plaid-api"; ``` @@ -71,17 +73,17 @@ Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, | Group | Environment | Base URL | Override at | | --- | --- | --- | --- | -| `default` | `production` *(default)* | `https://production.plaid.com` | `serverOptions.default.production.baseUrl` | -| `default` | `environment2` | `https://sandbox.plaid.com` | `serverOptions.default.environment2.baseUrl` | -| `accessTokenServer` | `production` *(default)* | `https://api.plaid.com/oauth2/apiv2` | `serverOptions.accessTokenServer.production.baseUrl` | -| `accessTokenServer` | `environment2` | `https://api.plaid.com/oauth2/apiv2` | `serverOptions.accessTokenServer.environment2.baseUrl` | +| `default` | `production` *(default)* | `https://production.plaid.com` | `serverOptions.default.baseUrl` | +| `default` | `environment2` | `https://sandbox.plaid.com` | `serverOptions.default.baseUrl` | +| `accessTokenServer` | `production` *(default)* | `https://api.plaid.com/oauth2/apiv2` | `serverOptions.accessTokenServer.baseUrl` | +| `accessTokenServer` | `environment2` | `https://api.plaid.com/oauth2/apiv2` | `serverOptions.accessTokenServer.baseUrl` | Consequences to state on every contract sheet that touches configuration: - Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. +- An environment value the SDK does not know throws `ConfigurationError` **from the constructor** — every server group is resolved once, as the client is built — so no operation method throws synchronously. ## Auth pattern (4 schemes) @@ -109,9 +111,9 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. -**OAuth 2 fetches and caches its own token.** The token request goes through **the same client** — same `timeout`, same `fetch` — sends a form-urlencoded body, and decodes the response against a schema rather than casting it. A token is cached until shortly before it expires; a response with no `expires_in` is treated as never expiring (RFC 6749 §5.1); concurrent callers share one in-flight fetch. **A refused token endpoint rejects with `AuthError`**, wrapping the underlying `ResponseError` as `cause` — so a bad secret never looks like the business call failing, and `AuthError` is *not* a `ResponseError`. +**OAuth 2 fetches and caches its own token.** The token request goes through **the same client** — same `fetch`, same `retry` policy — sends a form-urlencoded body, and decodes the response against a schema rather than casting it. A token is cached until shortly before it expires; a response with no `expires_in` is treated as never expiring (RFC 6749 §5.1); concurrent callers share one in-flight fetch. **A refused token endpoint rejects with `AuthError`**, wrapping the underlying `ApiError` as `cause` — so a bad secret never looks like the business call failing, and `AuthError` is *not* an `ApiError`. The token request is itself a `POST` with its own timeout, retried only when `httpMethodsToRetry` names `POST`. | Flow | Token endpoint | Client credentials travel | | --- | --- | --- | @@ -129,7 +131,7 @@ This SDK groups nothing: every operation is a method on `client` itself, so ther 335 operations are methods on the client itself: `client.accountsBalanceGet` · `client.accountsGet` · `client.applicationGet` · `client.assetReportAuditCopyCreate` · `client.assetReportAuditCopyGet` · `client.assetReportAuditCopyPdfGet` · `client.assetReportAuditCopyRemove` · `client.assetReportCreate` · `client.assetReportFilter` · `client.assetReportGet` · `client.assetReportPdfGet` · `client.assetReportRefresh` · `client.assetReportRemove` · `client.authGet` · `client.authVerify` · `client.bankTransferBalanceGet` · `client.bankTransferCancel` · `client.bankTransferCreate` · `client.bankTransferEventList` · `client.bankTransferEventSync` · `client.bankTransferGet` · `client.bankTransferList` · `client.bankTransferMigrateAccount` · `client.bankTransferSweepGet` · `client.bankTransferSweepList` · `client.beaconAccountRiskEvaluate` · `client.beaconDuplicateGet` · `client.beaconReportCreate` · `client.beaconReportGet` · `client.beaconReportList` · `client.beaconReportSyndicationGet` · `client.beaconReportSyndicationList` · `client.beaconUserAccountInsightsGet` · `client.beaconUserCreate` · `client.beaconUserGet` · `client.beaconUserHistoryList` · `client.beaconUserReview` · `client.beaconUserUpdate` · `client.betaEwaReportV1Get` · `client.betaPartnerCustomerV1Create` · `client.betaPartnerCustomerV1Enable` · `client.betaPartnerCustomerV1Get` · `client.betaPartnerCustomerV1Update` · `client.businessVerificationCreate` · `client.businessVerificationGet` · `client.cashflowReportGet` · `client.cashflowReportInsightsGet` · `client.cashflowReportRefresh` · `client.cashflowReportTransactionsGet` · `client.categoriesGet` · `client.consentEventsGet` · `client.consumerReportPdfGet` · `client.craCheckReportBaseReportGet` · `client.craCheckReportCashflowInsightsGet` · `client.craCheckReportCreate` · `client.craCheckReportIncomeInsightsGet` · `client.craCheckReportLendScoreGet` · `client.craCheckReportNetworkInsightsGet` · `client.craCheckReportPartnerInsightsGet` · `client.craCheckReportPdfGet` · `client.craCheckReportVerificationGet` · `client.craCheckReportVerificationPdfGet` · `client.craCreditProfileReportGet` · `client.craLoansApplicationsRegister` · `client.craLoansRegister` · `client.craLoansUnregister` · `client.craLoansUpdate` · `client.craMonitoringInsightsGet` · `client.craMonitoringInsightsSubscribe` · `client.craMonitoringInsightsUnsubscribe` · `client.craPartnerInsightsGet` · `client.craReportGet` · `client.createPaymentToken` · `client.creditAssetReportFreddieMacGet` · `client.creditAuditCopyTokenCreate` · `client.creditAuditCopyTokenUpdate` · `client.creditBankEmploymentGet` · `client.creditBankIncomeGet` · `client.creditBankIncomePdfGet` · `client.creditBankIncomeRefresh` · `client.creditBankIncomeWebhookUpdate` · `client.creditBankStatementsUploadsGet` · `client.creditEmploymentGet` · `client.creditFreddieMacReportsGet` · `client.creditPayrollIncomeGet` · `client.creditPayrollIncomeParsingConfigUpdate` · `client.creditPayrollIncomePrecheck` · `client.creditPayrollIncomeRefresh` · `client.creditPayrollIncomeRiskSignalsGet` · `client.creditRelayCreate` · `client.creditRelayGet` · `client.creditRelayPdfGet` · `client.creditRelayRefresh` · `client.creditRelayRemove` · `client.creditReportAuditCopyRemove` · `client.creditSessionsGet` · `client.dashboardUserGet` · `client.dashboardUserList` · `client.employersSearch` · `client.employmentVerificationGet` · `client.fdxConsentsGet` · `client.fdxConsentsList` · `client.fdxConsentsRevocationGet` · `client.fdxConsentsRevoke` · `client.fdxNotifications` · `client.getRecipient` · `client.getRecipients` · `client.identityDocumentsUploadsGet` · `client.identityGet` · `client.identityMatch` · `client.identityRefresh` · `client.identityVerificationAutofillCreate` · `client.identityVerificationCreate` · `client.identityVerificationGet` · `client.identityVerificationList` · `client.identityVerificationRetry` · `client.incomeVerificationCreate` · `client.incomeVerificationDocumentsDownload` · `client.incomeVerificationPaystubsGet` · `client.incomeVerificationPrecheck` · `client.incomeVerificationTaxformsGet` · `client.institutionsGet` · `client.institutionsGetById` · `client.institutionsSearch` · `client.investmentsAuthGet` · `client.investmentsHoldingsGet` · `client.investmentsRefresh` · `client.investmentsTransactionsGet` · `client.issuesGet` · `client.issuesSearch` · `client.issuesSubscribe` · `client.itemAccessTokenInvalidate` · `client.itemActivityList` · `client.itemApplicationList` · `client.itemApplicationScopesUpdate` · `client.itemApplicationUnlink` · `client.itemCreatePublicToken` · `client.itemGet` · `client.itemImport` · `client.itemProductsTerminate` · `client.itemPublicTokenExchange` · `client.itemRemove` · `client.itemWebhookUpdate` · `client.liabilitiesGet` · `client.linkDeliveryCreate` · `client.linkDeliveryGet` · `client.linkOauthCorrelationIdExchange` · `client.linkTokenCreate` · `client.linkTokenGet` · `client.networkStatusGet` · `client.oauthIntrospect` · `client.oauthRevoke` · `client.oauthToken` · `client.partnerCustomerCreate` · `client.partnerCustomerEnable` · `client.partnerCustomerGet` · `client.partnerCustomerOauthInstitutionsGet` · `client.partnerCustomerRemove` · `client.paymentInitiationConsentCreate` · `client.paymentInitiationConsentGet` · `client.paymentInitiationConsentPaymentExecute` · `client.paymentInitiationConsentRevoke` · `client.paymentInitiationPaymentCreate` · `client.paymentInitiationPaymentGet` · `client.paymentInitiationPaymentList` · `client.paymentInitiationPaymentReverse` · `client.paymentInitiationRecipientCreate` · `client.paymentInitiationRecipientGet` · `client.paymentInitiationRecipientList` · `client.paymentProfileCreate` · `client.paymentProfileGet` · `client.paymentProfileRemove` · `client.processorAccountGet` · `client.processorApexProcessorTokenCreate` · `client.processorAuthGet` · `client.processorBalanceGet` · `client.processorBankTransferCreate` · `client.processorIdentityGet` · `client.processorIdentityMatch` · `client.processorInvestmentsAuthGet` · `client.processorInvestmentsHoldingsGet` · `client.processorInvestmentsTransactionsGet` · `client.processorLiabilitiesGet` · `client.processorSignalDecisionReport` · `client.processorSignalEvaluate` · `client.processorSignalPrepare` · `client.processorSignalReturnReport` · `client.processorStripeBankAccountTokenCreate` · `client.processorTokenCreate` · `client.processorTokenPermissionsGet` · `client.processorTokenPermissionsSet` · `client.processorTokenWebhookUpdate` · `client.processorTransactionsGet` · `client.processorTransactionsRecurringGet` · `client.processorTransactionsRefresh` · `client.processorTransactionsSync` · `client.profileNetworkStatusGet` · `client.protectCompute` · `client.protectEventGet` · `client.protectEventSend` · `client.protectReportCreate` · `client.protectUserInsightsGet` · `client.sandboxBankIncomeFireWebhook` · `client.sandboxBankTransferFireWebhook` · `client.sandboxBankTransferSimulate` · `client.sandboxCraCashflowUpdatesUpdate` · `client.sandboxFdxConsentSeed` · `client.sandboxIncomeFireWebhook` · `client.sandboxItemApplicationSeed` · `client.sandboxItemFireWebhook` · `client.sandboxItemResetLogin` · `client.sandboxItemSetVerificationStatus` · `client.sandboxOauthSelectAccounts` · `client.sandboxPaymentProfileResetLogin` · `client.sandboxPaymentSimulate` · `client.sandboxProcessorTokenCreate` · `client.sandboxPublicTokenCreate` · `client.sandboxTransactionsCreate` · `client.sandboxTransferFireWebhook` · `client.sandboxTransferLedgerDepositSimulate` · `client.sandboxTransferLedgerSimulateAvailable` · `client.sandboxTransferLedgerWithdrawSimulate` · `client.sandboxTransferRefundSimulate` · `client.sandboxTransferRepaymentSimulate` · `client.sandboxTransferSimulate` · `client.sandboxTransferSweepSimulate` · `client.sandboxTransferTestClockAdvance` · `client.sandboxTransferTestClockCreate` · `client.sandboxTransferTestClockGet` · `client.sandboxTransferTestClockList` · `client.sandboxUserResetLogin` · `client.sessionTokenCreate` · `client.signalDecisionReport` · `client.signalEvaluate` · `client.signalPrepare` · `client.signalReturnReport` · `client.signalSchedule` · `client.statementsDownload` · `client.statementsList` · `client.statementsRefresh` · `client.transactionsEnhance` · `client.transactionsEnrich` · `client.transactionsGet` · `client.transactionsRecurringGet` · `client.transactionsRefresh` · `client.transactionsRulesCreate` · `client.transactionsRulesList` · `client.transactionsRulesRemove` · `client.transactionsSync` · `client.transactionsUserInsightsGet` · `client.transferAuthorizationCancel` · `client.transferAuthorizationCreate` · `client.transferBalanceGet` · `client.transferCancel` · `client.transferCapabilitiesGet` · `client.transferConfigurationGet` · `client.transferCreate` · `client.transferDiligenceDocumentUpload` · `client.transferDiligenceSubmit` · `client.transferEventList` · `client.transferEventSync` · `client.transferGet` · `client.transferIntentCreate` · `client.transferIntentGet` · `client.transferLedgerDeposit` · `client.transferLedgerDistribute` · `client.transferLedgerEventList` · `client.transferLedgerGet` · `client.transferLedgerWithdraw` · `client.transferList` · `client.transferMetricsGet` · `client.transferMigrateAccount` · `client.transferOriginatorCreate` · `client.transferOriginatorFundingAccountCreate` · `client.transferOriginatorFundingAccountUpdate` · `client.transferOriginatorGet` · `client.transferOriginatorList` · `client.transferPlatformOriginatorCreate` · `client.transferPlatformPersonCreate` · `client.transferPlatformRequirementSubmit` · `client.transferQuestionnaireCreate` · `client.transferRecurringCancel` · `client.transferRecurringCreate` · `client.transferRecurringGet` · `client.transferRecurringList` · `client.transferRefundCancel` · `client.transferRefundCreate` · `client.transferRefundGet` · `client.transferRepaymentList` · `client.transferRepaymentReturnList` · `client.transferReturnRecover` · `client.transferSweepGet` · `client.transferSweepList` · `client.userAccountSessionEventSend` · `client.userAccountSessionGet` · `client.userCreate` · `client.userFinancialDataRefresh` · `client.userGet` · `client.userIdentityRemove` · `client.userItemsAssociate` · `client.userItemsGet` · `client.userItemsRemove` · `client.userProductsTerminate` · `client.userRemove` · `client.userThirdPartyTokenCreate` · `client.userThirdPartyTokenRemove` · `client.userTransactionsRefresh` · `client.userUpdate` · `client.walletCreate` · `client.walletGet` · `client.walletList` · `client.walletTransactionExecute` · `client.walletTransactionGet` · `client.walletTransactionList` · `client.watchlistScreeningEntityCreate` · `client.watchlistScreeningEntityGet` · `client.watchlistScreeningEntityHistoryList` · `client.watchlistScreeningEntityHitList` · `client.watchlistScreeningEntityList` · `client.watchlistScreeningEntityProgramGet` · `client.watchlistScreeningEntityProgramList` · `client.watchlistScreeningEntityReviewCreate` · `client.watchlistScreeningEntityReviewList` · `client.watchlistScreeningEntityUpdate` · `client.watchlistScreeningIndividualCreate` · `client.watchlistScreeningIndividualGet` · `client.watchlistScreeningIndividualHistoryList` · `client.watchlistScreeningIndividualHitList` · `client.watchlistScreeningIndividualList` · `client.watchlistScreeningIndividualProgramGet` · `client.watchlistScreeningIndividualProgramList` · `client.watchlistScreeningIndividualReviewCreate` · `client.watchlistScreeningIndividualReviewList` · `client.watchlistScreeningIndividualUpdate` · `client.webhookVerificationKeyGet`. -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** 317 operations take `RequestParams` instead: `accountsBalanceGet`, `accountsGet`, `applicationGet`, `assetReportAuditCopyCreate`, `assetReportAuditCopyGet`, `assetReportAuditCopyPdfGet`, `assetReportAuditCopyRemove`, `assetReportCreate`, `assetReportFilter`, `assetReportGet`, `assetReportPdfGet`, `assetReportRefresh`, `assetReportRemove`, `authGet`, `authVerify`, `bankTransferBalanceGet`, `bankTransferCancel`, `bankTransferCreate`, `bankTransferEventList`, `bankTransferEventSync`, `bankTransferGet`, `bankTransferList`, `bankTransferMigrateAccount`, `bankTransferSweepGet`, `bankTransferSweepList`, `beaconAccountRiskEvaluate`, `beaconDuplicateGet`, `beaconReportCreate`, `beaconReportGet`, `beaconReportList`, `beaconReportSyndicationGet`, `beaconReportSyndicationList`, `beaconUserAccountInsightsGet`, `beaconUserCreate`, `beaconUserGet`, `beaconUserHistoryList`, `beaconUserReview`, `beaconUserUpdate`, `betaEwaReportV1Get`, `betaPartnerCustomerV1Create`, `betaPartnerCustomerV1Enable`, `betaPartnerCustomerV1Get`, `betaPartnerCustomerV1Update`, `businessVerificationCreate`, `businessVerificationGet`, `cashflowReportGet`, `cashflowReportInsightsGet`, `cashflowReportRefresh`, `cashflowReportTransactionsGet`, `consentEventsGet`, `consumerReportPdfGet`, `craCheckReportBaseReportGet`, `craCheckReportCashflowInsightsGet`, `craCheckReportCreate`, `craCheckReportIncomeInsightsGet`, `craCheckReportLendScoreGet`, `craCheckReportNetworkInsightsGet`, `craCheckReportPartnerInsightsGet`, `craCheckReportPdfGet`, `craCheckReportVerificationGet`, `craCheckReportVerificationPdfGet`, `craCreditProfileReportGet`, `craLoansApplicationsRegister`, `craLoansRegister`, `craLoansUnregister`, `craLoansUpdate`, `craMonitoringInsightsGet`, `craMonitoringInsightsSubscribe`, `craMonitoringInsightsUnsubscribe`, `craPartnerInsightsGet`, `craReportGet`, `creditAuditCopyTokenCreate`, `creditAuditCopyTokenUpdate`, `creditBankEmploymentGet`, `creditBankIncomeGet`, `creditBankIncomePdfGet`, `creditBankIncomeRefresh`, `creditBankIncomeWebhookUpdate`, `creditBankStatementsUploadsGet`, `creditEmploymentGet`, `creditFreddieMacReportsGet`, `creditPayrollIncomeGet`, `creditPayrollIncomeParsingConfigUpdate`, `creditPayrollIncomePrecheck`, `creditPayrollIncomeRefresh`, `creditPayrollIncomeRiskSignalsGet`, `creditRelayCreate`, `creditRelayGet`, `creditRelayPdfGet`, `creditRelayRefresh`, `creditRelayRemove`, `creditSessionsGet`, `dashboardUserGet`, `dashboardUserList`, `employersSearch`, `employmentVerificationGet`, `identityDocumentsUploadsGet`, `identityGet`, `identityMatch`, `identityRefresh`, `identityVerificationAutofillCreate`, `identityVerificationCreate`, `identityVerificationGet`, `identityVerificationList`, `identityVerificationRetry`, `incomeVerificationCreate`, `incomeVerificationDocumentsDownload`, `incomeVerificationPaystubsGet`, `incomeVerificationPrecheck`, `incomeVerificationTaxformsGet`, `institutionsGet`, `institutionsGetById`, `institutionsSearch`, `investmentsAuthGet`, `investmentsHoldingsGet`, `investmentsRefresh`, `investmentsTransactionsGet`, `issuesGet`, `issuesSearch`, `issuesSubscribe`, `itemAccessTokenInvalidate`, `itemActivityList`, `itemApplicationList`, `itemApplicationScopesUpdate`, `itemApplicationUnlink`, `itemGet`, `itemImport`, `itemProductsTerminate`, `itemPublicTokenExchange`, `itemRemove`, `itemWebhookUpdate`, `liabilitiesGet`, `linkDeliveryCreate`, `linkDeliveryGet`, `linkTokenCreate`, `linkTokenGet`, `networkStatusGet`, `partnerCustomerCreate`, `partnerCustomerEnable`, `partnerCustomerGet`, `partnerCustomerRemove`, `paymentInitiationConsentCreate`, `paymentInitiationConsentGet`, `paymentInitiationConsentPaymentExecute`, `paymentInitiationConsentRevoke`, `paymentInitiationPaymentCreate`, `paymentInitiationPaymentGet`, `paymentInitiationPaymentList`, `paymentInitiationPaymentReverse`, `paymentInitiationRecipientCreate`, `paymentInitiationRecipientGet`, `paymentInitiationRecipientList`, `paymentProfileCreate`, `paymentProfileGet`, `paymentProfileRemove`, `processorAccountGet`, `processorApexProcessorTokenCreate`, `processorAuthGet`, `processorBalanceGet`, `processorBankTransferCreate`, `processorIdentityGet`, `processorIdentityMatch`, `processorInvestmentsAuthGet`, `processorInvestmentsHoldingsGet`, `processorInvestmentsTransactionsGet`, `processorLiabilitiesGet`, `processorSignalDecisionReport`, `processorSignalEvaluate`, `processorSignalPrepare`, `processorSignalReturnReport`, `processorStripeBankAccountTokenCreate`, `processorTokenCreate`, `processorTokenPermissionsGet`, `processorTokenPermissionsSet`, `processorTokenWebhookUpdate`, `processorTransactionsGet`, `processorTransactionsRecurringGet`, `processorTransactionsRefresh`, `processorTransactionsSync`, `profileNetworkStatusGet`, `protectCompute`, `protectEventGet`, `protectEventSend`, `protectReportCreate`, `protectUserInsightsGet`, `sandboxBankIncomeFireWebhook`, `sandboxBankTransferFireWebhook`, `sandboxBankTransferSimulate`, `sandboxCraCashflowUpdatesUpdate`, `sandboxFdxConsentSeed`, `sandboxIncomeFireWebhook`, `sandboxItemApplicationSeed`, `sandboxItemFireWebhook`, `sandboxItemResetLogin`, `sandboxItemSetVerificationStatus`, `sandboxOauthSelectAccounts`, `sandboxPaymentProfileResetLogin`, `sandboxPaymentSimulate`, `sandboxProcessorTokenCreate`, `sandboxPublicTokenCreate`, `sandboxTransactionsCreate`, `sandboxTransferFireWebhook`, `sandboxTransferLedgerDepositSimulate`, `sandboxTransferLedgerSimulateAvailable`, `sandboxTransferLedgerWithdrawSimulate`, `sandboxTransferRefundSimulate`, `sandboxTransferRepaymentSimulate`, `sandboxTransferSimulate`, `sandboxTransferSweepSimulate`, `sandboxTransferTestClockAdvance`, `sandboxTransferTestClockCreate`, `sandboxTransferTestClockGet`, `sandboxTransferTestClockList`, `sandboxUserResetLogin`, `sessionTokenCreate`, `signalDecisionReport`, `signalEvaluate`, `signalPrepare`, `signalReturnReport`, `signalSchedule`, `statementsDownload`, `statementsList`, `statementsRefresh`, `transactionsEnrich`, `transactionsGet`, `transactionsRecurringGet`, `transactionsRefresh`, `transactionsRulesCreate`, `transactionsRulesList`, `transactionsRulesRemove`, `transactionsSync`, `transactionsUserInsightsGet`, `transferAuthorizationCancel`, `transferAuthorizationCreate`, `transferBalanceGet`, `transferCancel`, `transferCapabilitiesGet`, `transferConfigurationGet`, `transferCreate`, `transferDiligenceDocumentUpload`, `transferDiligenceSubmit`, `transferEventList`, `transferEventSync`, `transferGet`, `transferIntentCreate`, `transferIntentGet`, `transferLedgerDeposit`, `transferLedgerDistribute`, `transferLedgerEventList`, `transferLedgerGet`, `transferLedgerWithdraw`, `transferList`, `transferMetricsGet`, `transferMigrateAccount`, `transferOriginatorCreate`, `transferOriginatorFundingAccountCreate`, `transferOriginatorFundingAccountUpdate`, `transferOriginatorGet`, `transferOriginatorList`, `transferPlatformOriginatorCreate`, `transferPlatformPersonCreate`, `transferPlatformRequirementSubmit`, `transferQuestionnaireCreate`, `transferRecurringCancel`, `transferRecurringCreate`, `transferRecurringGet`, `transferRecurringList`, `transferRefundCancel`, `transferRefundCreate`, `transferRefundGet`, `transferRepaymentList`, `transferRepaymentReturnList`, `transferReturnRecover`, `transferSweepGet`, `transferSweepList`, `userAccountSessionEventSend`, `userAccountSessionGet`, `userCreate`, `userFinancialDataRefresh`, `userGet`, `userIdentityRemove`, `userItemsAssociate`, `userItemsGet`, `userItemsRemove`, `userProductsTerminate`, `userRemove`, `userThirdPartyTokenCreate`, `userThirdPartyTokenRemove`, `userTransactionsRefresh`, `userUpdate`, `walletCreate`, `walletGet`, `walletList`, `walletTransactionExecute`, `walletTransactionGet`, `walletTransactionList`, `watchlistScreeningEntityCreate`, `watchlistScreeningEntityGet`, `watchlistScreeningEntityHistoryList`, `watchlistScreeningEntityHitList`, `watchlistScreeningEntityList`, `watchlistScreeningEntityProgramGet`, `watchlistScreeningEntityProgramList`, `watchlistScreeningEntityReviewCreate`, `watchlistScreeningEntityReviewList`, `watchlistScreeningEntityUpdate`, `watchlistScreeningIndividualCreate`, `watchlistScreeningIndividualGet`, `watchlistScreeningIndividualHistoryList`, `watchlistScreeningIndividualHitList`, `watchlistScreeningIndividualList`, `watchlistScreeningIndividualProgramGet`, `watchlistScreeningIndividualProgramList`, `watchlistScreeningIndividualReviewCreate`, `watchlistScreeningIndividualReviewList`, `watchlistScreeningIndividualUpdate`, `webhookVerificationKeyGet`. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -137,7 +139,7 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: @@ -146,11 +148,11 @@ Locate the installed package before you rely on a lookup: node -e "console.log(require.resolve('the-plaid-api/package.json'))" ``` -Failing that it is at `node_modules/the-plaid-api/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/the-plaid-api/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/the-plaid-api/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -158,7 +160,7 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/the-plaid-api/`: @@ -168,13 +170,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -188,17 +192,23 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `THE_PLAID_API_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new ThePlaidApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the OAuth 2 token cache lives on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) -2. **Authentication** — load **typescript-authentication** before you set credentials. The 4 schemes are `clientId`, `secret`, `plaidVersion`, `oauth2` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; the token is fetched lazily and cached on the client; a **failed token fetch** raises `AuthError`, which is not a `ResponseError` and **bypasses `.asApiResult()` entirely**; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new ThePlaidApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the OAuth 2 token cache lives on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) +2. **Authentication** — load **typescript-authentication** before you set credentials. The 4 schemes are `clientId`, `secret`, `plaidVersion`, `oauth2` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; the token is fetched lazily and cached on the client; a **failed token fetch** raises `AuthError`, which is not an `ApiError` and **bypasses `.asApiResult()` entirely**; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **11 operations resolve to `undefined`**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `ThePlaidApiError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `ThePlaidApiError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `THE_PLAID_API_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK @@ -207,11 +217,13 @@ Beyond the usual signatures and model members, a contract sheet for the The Plai 1. **Which host each deployment talks to**, and where that is set. The members are `ServerEnvironment.Production`, `ServerEnvironment.Environment2`, defaulting to `ServerEnvironment.Production` when the field is unset. 2. **11 operations resolve to `undefined`** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet — 317 operations take `RequestParams`, not `Request`. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 289 of 335 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. **That `Error` imported from this package is a model type, not the global of that name** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `ThePlaidApiError` for the same reason. -10. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 289 of 335 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. **That `Error` imported from this package is a model type, not the global of that name** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `ThePlaidApiError` for the same reason. +12. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/plaid/skills/typescript/typescript-integrate-the-plaid-api/SKILL.md b/plugins/plaid/skills/typescript/typescript-integrate-the-plaid-api/SKILL.md index 7630507e4..9d7abce14 100644 --- a/plugins/plaid/skills/typescript/typescript-integrate-the-plaid-api/SKILL.md +++ b/plugins/plaid/skills/typescript/typescript-integrate-the-plaid-api/SKILL.md @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/the-plaid-api/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('the-plaid-api/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('the-plaid-api/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. @@ -23,7 +23,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/the-plaid-api-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/the-plaid-api-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** @@ -40,11 +40,85 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** installing `the-plaid-api` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. @@ -63,8 +137,10 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from the **11 operations that resolve to `undefined`** (every operation whose success declares no body — the map's **Returns** bullet names them). `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `ThePlaidApiError` family, told apart by `err.kind` — and an unknown `serverEnvironment` throws `ConfigurationError` out of the client constructor, before any call exists. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `THE_PLAID_API_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions @@ -74,7 +150,7 @@ A standalone The Plaid API question with no code change: look it up in `typescri - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. - **Never write any The Plaid API or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. - **Don't web-search The Plaid API topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public The Plaid API docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. diff --git a/plugins/plaid/skills/typescript/typescript-models/SKILL.md b/plugins/plaid/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/plaid/skills/typescript/typescript-models/SKILL.md +++ b/plugins/plaid/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/plaid/skills/typescript/typescript-models/reference.md b/plugins/plaid/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/plaid/skills/typescript/typescript-models/reference.md +++ b/plugins/plaid/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/plaid/skills/typescript/typescript-testing/SKILL.md b/plugins/plaid/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/plaid/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/plaid/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/shutterstock/.claude-plugin/plugin.json b/plugins/shutterstock/.claude-plugin/plugin.json index d6a4dedd2..0cb3be286 100644 --- a/plugins/shutterstock/.claude-plugin/plugin.json +++ b/plugins/shutterstock/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "shutterstock", "displayName": "Shutterstock API Explorer", - "version": "0.3.4", + "version": "0.4.0", "description": "Integrate and consume the APIMatic-generated Shutterstock API Explorer SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/shutterstock/.cursor-plugin/plugin.json b/plugins/shutterstock/.cursor-plugin/plugin.json index d6a4dedd2..0cb3be286 100644 --- a/plugins/shutterstock/.cursor-plugin/plugin.json +++ b/plugins/shutterstock/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "shutterstock", "displayName": "Shutterstock API Explorer", - "version": "0.3.4", + "version": "0.4.0", "description": "Integrate and consume the APIMatic-generated Shutterstock API Explorer SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/shutterstock/plugin.json b/plugins/shutterstock/plugin.json index d6a4dedd2..0cb3be286 100644 --- a/plugins/shutterstock/plugin.json +++ b/plugins/shutterstock/plugin.json @@ -1,7 +1,7 @@ { "name": "shutterstock", "displayName": "Shutterstock API Explorer", - "version": "0.3.4", + "version": "0.4.0", "description": "Integrate and consume the APIMatic-generated Shutterstock API Explorer SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/shutterstock/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/shutterstock/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..58e01b155 100644 --- a/plugins/shutterstock/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/shutterstock/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,86 +1,42 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Shutterstock API Explorer .NET SDK in C# — this API authenticates with HTTP Basic and the OAuth 2.0 authorization-code grant, and this skill gives the credential each scheme takes, the ShutterstockApiExplorerClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Shutterstock API Explorer .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with HTTP Basic and the OAuth 2.0 authorization-code grant. APIMatic surfaces each scheme as a **nullable credentials property** on `ShutterstockApiExplorerClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) +| Scheme | Property on `ShutterstockApiExplorerClientOptions` | Credential | +| --- | --- | --- | +| HTTP Basic | `Basic` | `BasicAuthCredentials` | +| OAuth 2.0 authorization code | `CustomerAccessCode` | `OAuth2AuthorizationCodeCredentials` | -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). +**That table is the entire authentication surface of this SDK.** `ShutterstockApiExplorer.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. ## Basic auth ```csharp -using {RootNamespace}.Core.Authentication.Basic; +using ShutterstockApiExplorer.Core.Authentication.Basic; -options.{BasicAuthProperty} = new BasicAuthCredentials +options.Basic = new BasicAuthCredentials { Username = "...", Password = "..." }; ``` -Sends `Authorization: Basic base64(username:password)`. - -## Bearer token - -Set the configured token property on the options class to your access-token string: - -```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; -``` - -Sends `Authorization: Bearer ACCESS_TOKEN`. - -## API key (header, query, or cookie) - -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: - -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` - -## OAuth 2.0 — client credentials (machine-to-machine) - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; - -options.{OAuthProperty} = new OAuth2ClientCredentials -{ - ClientId = "...", - ClientSecret = "...", - Scope = "..." // optional -}; -``` - -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. +Sends `Authorization: Basic base64(username:password)`. Both halves are required — a username with an empty password is misconfigured, not partially configured. ## OAuth 2.0 — authorization code (3-legged, with PKCE) ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; +using ShutterstockApiExplorer.Core.Authentication.OAuth2.AuthorizationCode; -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials +options.CustomerAccessCode = new OAuth2AuthorizationCodeCredentials { ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE + ClientSecret = "...", RedirectUri = "https://app.example.com/callback", Scope = "...", // optional State = "...", // optional CSRF token @@ -94,152 +50,95 @@ options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials }; ``` -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. +The SDK sends the user to `https://accounts.shutterstock.com/oauth/authorize`, exchanges the returned code for a token at `https://api.shutterstock.com/v2/oauth/access_token`, and refreshes it when it expires; if the refresh fails, it invokes `PromptForAuthorizationCode` again to re-authorize. -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: +`PromptForAuthorizationCode` runs inside the call that needs a new token, under the scheme's fetch lock, so it must not make an SDK call that uses this scheme. It counts against the per-attempt `options.Retry.Timeout`, 100 s by default, and when the call is retryable (by default a `GET`, `HEAD`, `PUT` or `OPTIONS`) a timed-out attempt is retried and prompts again. A prompt that throws anything but `OperationCanceledException` fails the call with `AuthSchemeException`. `State` is sent as you set it and never checked, so your redirect handler must verify it. -| PKCE | token request built | no secret ⇒ | -| --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | +⚠ **`ClientSecret` is required here — PKCE does not make it optional.** This SDK builds the token request with `OAuth2AuthorizationCodeStrategy.ForBasicAuthRequest`, which sends the client id and secret as HTTP Basic credentials on the exchange. Omit it and the call fails with an `AuthSchemeException` wrapping an `InvalidOperationException`, at one of two very different moments: -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. +- **PKCE disabled** (`Pkce = null`) — throws *before* `PromptForAuthorizationCode` runs. +- **PKCE enabled** (the default `S256`) — throws *after* the prompt has already run. The user completes a full browser round-trip, and only then does the exchange fail. This is the expensive one. -## OAuth 2.0 — resource owner password +The public-client carve-out — a secret-less PKCE flow — belongs to the form-body token request, which this SDK does not use. Do not plan around it here. -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; +## Token caching & refresh -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` +- Tokens are cached in-memory, **per client instance**, and reused until **30s** before expiry. +- **This grant refreshes.** The authorization-code grant is wired to `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`, so an expired access token is exchanged for a new one using the refresh token. +- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: a `401` there is an interactive re-authorization, not a silent refresh. **Plan for that in a non-interactive host** — a background worker will block on a prompt nobody can answer. +- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of a fresh token. -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. +⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** `expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to "not expired" when it is absent — so the first token is cached for the life of the client and the only thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. ### Supplying your own token source -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. +The token strategy is a public extension point: `ShutterstockApiExplorerClientOptions` exposes a strategy property beside each credentials property, and the generated client falls back to the built-in strategy only when you leave it null. The strategy is used only when the credentials property beside it is also set, and `GetToken` receives that object. + +| Grant | Strategy property on `ShutterstockApiExplorerClientOptions` | Type | +| --- | --- | --- | +| Authorization code | `CustomerAccessCodeTokenStrategy` | `IOAuth2RefreshableTokenStrategy` | ```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) +options.CustomerAccessCode = new OAuth2AuthorizationCodeCredentials { /* its required members */ }; +options.CustomerAccessCodeTokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) + // Task TryRefreshToken(creds, refreshToken, ct) ``` -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. +Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test double. The per-client in-memory cache above still wraps whatever you return. -## Combined / multiple schemes +## Combining the schemes -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: +This SDK generates more than one credentials property, and its operations compose them. You configure the composition by setting the relevant properties on `ShutterstockApiExplorerClientOptions` — the generated client wires the rest. -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). +**OR (`AuthSchemeAny`) — alternatives, tried in order.** Schemes with **no credentials configured are skipped, not tried**; the first configured scheme that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails. Configure exactly the one you intend to use — an extra credential set "just in case" changes which scheme authenticates the call. -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. +## Operations that need no credentials -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +Some operations on this API are public: the generated endpoint uses `NoneAuthScheme` and sends no credential, whatever you set on `ShutterstockApiExplorerClientOptions`. You still construct the same client — there is no separate unauthenticated client — and the credentials you configured are simply not applied to those calls. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `ShutterstockApiExplorerClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("ShutterstockApiExplorerClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.Username)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "ShutterstockApiExplorerClient:Username is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"ShutterstockApiExplorerClient:Username is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +Check every value **the scheme you chose** needs. The rows below are not one checklist: the schemes above are alternatives, so a row belonging to a scheme you are not using needs no value at all, and demanding one at startup blocks a boot that would have worked. Validate the rows whose property you set, and no others: + +| Value to validate | Feeds `ShutterstockApiExplorerClientOptions` property | +| --- | --- | +| `Username` | `Basic` | +| `Password` | `Basic` | +| `ClientId` | `CustomerAccessCode` | +| `ClientSecret` | `CustomerAccessCode` | +| `RedirectUri` | `CustomerAccessCode` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddShutterstockApiExplorerClient(options => ...)` callback when registering via DI. `ShutterstockApiExplorerClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddShutterstockApiExplorerClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/shutterstock/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/shutterstock/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/shutterstock/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/shutterstock/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/shutterstock/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/shutterstock/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/shutterstock/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/shutterstock/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/shutterstock/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/shutterstock/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/shutterstock/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/shutterstock/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/shutterstock/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/shutterstock/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/shutterstock/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/shutterstock/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException
` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/shutterstock/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/shutterstock/skills/dotnet/dotnet-getting-started/SKILL.md index f8e37a386..e1102ef76 100644 --- a/plugins/shutterstock/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/shutterstock/skills/dotnet/dotnet-getting-started/SKILL.md @@ -1,17 +1,17 @@ --- name: "dotnet-getting-started" -description: "Shutterstock API Explorer .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-shutterstock is the workflow that uses it." +description: "Shutterstock API Explorer .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-shutterstock-api-explorer is the workflow that uses it." --- # Getting started with the Shutterstock API Explorer .NET SDK -> **Who this skill is for.** This is the **map layer**: load it to ground every Shutterstock API Explorer SDK fact. `dotnet-integrate-shutterstock` is the workflow that drives it — you collect the contracts for the work in scope into `shutterstock-api-explorer-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. +> **Who this skill is for.** This is the **map layer**: load it to ground every Shutterstock API Explorer SDK fact. `dotnet-integrate-shutterstock-api-explorer` is the workflow that drives it — you collect the contracts for the work in scope into `shutterstock-api-explorer-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated .NET SDK (auth, calling endpoints, models, error handling, retries, testing), see the companion API-agnostic skills: `dotnet-client-initialization`, `dotnet-authentication`, `dotnet-calling-endpoints`, `dotnet-models`, `dotnet-error-handling`, `dotnet-configuration-resilience`, `dotnet-testing`. **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity @@ -26,9 +26,9 @@ This is the **SDK-specific** entry point. For general patterns that apply to any | Auth | HTTP **Basic** — set `Basic`
**OAuth 2.0** authorization code — set `CustomerAccessCode` | | Environments | `ServerEnvironment.Production` *(default)* → `Default` `https://api.shutterstock.com`, `AuthServer` `https://accounts.shutterstock.com/oauth`
`ServerEnvironment.Environment2` → `Default` `https://api-sandbox.shutterstock.com`, `AuthServer` `https://accounts.shutterstock.com/oauth` | | Log environment variable | `SHUTTERSTOCKAPIEXPLORERCLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | -The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-shutterstock` makes that binding); the **Integration workflow** below says which governs which step. +The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-shutterstock-api-explorer` makes that binding); the **Integration workflow** below says which governs which step. ## Namespaces (using-directives) @@ -44,22 +44,24 @@ Models, enums, union types and the generated error types each sit in their own c ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Shutterstock API Explorer, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/ShutterstockApiExplorer/Models/`, `src/ShutterstockApiExplorer/Errors/` and `src/ShutterstockApiExplorer/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/ShutterstockApiExplorer/Models/`, enums `src/ShutterstockApiExplorer/Models/Enums/`, typed errors `src/ShutterstockApiExplorer/Errors/`, request records `src/ShutterstockApiExplorer/Requests/`, or `src/ShutterstockApiExplorer/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: @@ -69,7 +71,7 @@ Keep lookups cheap — the rules that keep a session's context small: ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/shutterstock-api-explorer-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/shutterstock-api-explorer-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. **Clone once, up front**, into a fresh timestamped folder under `/shutterstock-api-explorer-sdk-src/`, and reuse that folder for the rest of your session: @@ -87,30 +89,31 @@ git clone --filter=blob:none --branch main https://github.com/context-plugins/sh # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/shutterstock-api-explorer-sdk-src/`, **never** in the project repo, and its path never goes into `shutterstock-api-explorer-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `shutterstock-api-explorer-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `ShutterstockApiExplorerClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/ShutterstockApiExplorer/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/ShutterstockApiExplorer/Models/` (+ `src/ShutterstockApiExplorer/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/ShutterstockApiExplorer/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/ShutterstockApiExplorer/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/ShutterstockApiExplorer/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/ShutterstockApiExplorer/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/ShutterstockApiExplorer/Servers/`, `src/ShutterstockApiExplorer/ShutterstockApiExplorerClient.cs`, `src/ShutterstockApiExplorer/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Shutterstock API Explorer may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything @@ -126,17 +129,17 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/ShutterstockApiExplorer/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step -You loaded every companion the sheet named before starting (per `dotnet-integrate-shutterstock`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: +You loaded every companion the sheet named before starting (per `dotnet-integrate-shutterstock-api-explorer`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: 1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new ShutterstockApiExplorerClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) 2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `ShutterstockApiExplorerClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/shutterstock/skills/dotnet/dotnet-integrate-shutterstock/SKILL.md b/plugins/shutterstock/skills/dotnet/dotnet-integrate-shutterstock-api-explorer/SKILL.md similarity index 65% rename from plugins/shutterstock/skills/dotnet/dotnet-integrate-shutterstock/SKILL.md rename to plugins/shutterstock/skills/dotnet/dotnet-integrate-shutterstock-api-explorer/SKILL.md index a62d5231a..ab10eb246 100644 --- a/plugins/shutterstock/skills/dotnet/dotnet-integrate-shutterstock/SKILL.md +++ b/plugins/shutterstock/skills/dotnet/dotnet-integrate-shutterstock-api-explorer/SKILL.md @@ -1,5 +1,5 @@ --- -name: "dotnet-integrate-shutterstock" +name: "dotnet-integrate-shutterstock-api-explorer" description: "MANDATORY FIRST STEP for Shutterstock API Explorer .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Shutterstock API Explorer in C# — AudioApi, Catalog, ComputerVision, Contributors, EditorialImages, EditorialVideo, Images, Oauth, SoundEffects, Test, Users, Videos — or when a Shutterstock API Explorer .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file shutterstock-api-explorer-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." --- @@ -11,7 +11,7 @@ You are the Shutterstock API Explorer .NET SDK specialist for this task — one ## Grounding rules -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `shutterstock-api-explorer-plan.md` — the plan must stay portable. +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `shutterstock-api-explorer-plan.md` never carries its path — the plan must stay portable. - **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. - **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. - **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. @@ -29,7 +29,7 @@ Your FIRST deliverable is `shutterstock-api-explorer-plan.md` at `` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. 3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. 4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. 5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. @@ -38,7 +38,7 @@ Your FIRST deliverable is `shutterstock-api-explorer-plan.md` at ``** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. 6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. 7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. @@ -67,12 +80,46 @@ Never use this prerequisite work as a "head start" on implementation: **creating | 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | | 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | | 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | | 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | | 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | | 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. @@ -95,14 +142,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. 3. After every change: `dotnet build`; fix non-SDK errors yourself. 4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `ShutterstockApiExplorer.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. ### Step 4 — Fixing SDK errors (map-first, in place) A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `ShutterstockApiExplorer.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/ShutterstockApiExplorer/Models/{Type}.cs`, `src/ShutterstockApiExplorer/Models/Enums/{Type}.cs`, `src/ShutterstockApiExplorer/Errors/{Type}.cs`, `src/ShutterstockApiExplorer/Requests/{Type}.cs`, or `src/ShutterstockApiExplorer/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. 3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". 4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). 5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. @@ -120,6 +168,6 @@ A standalone Shutterstock API Explorer question with no code change: load `dotne - **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. - **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. - **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search Shutterstock API Explorer for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `ShutterstockApiExplorer` is what the generated source declares, not what public docs describe. +- **Do not web-search Shutterstock API Explorer for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `ShutterstockApiExplorer` is what the generated source declares, not what public docs describe. - **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. diff --git a/plugins/shutterstock/skills/dotnet/dotnet-models/SKILL.md b/plugins/shutterstock/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/shutterstock/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/shutterstock/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/shutterstock/skills/dotnet/dotnet-models/reference.md b/plugins/shutterstock/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/shutterstock/skills/dotnet/dotnet-models/reference.md +++ b/plugins/shutterstock/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/shutterstock/skills/dotnet/dotnet-testing/SKILL.md b/plugins/shutterstock/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/shutterstock/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/shutterstock/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/shutterstock/skills/python/python-authentication/SKILL.md b/plugins/shutterstock/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/shutterstock/skills/python/python-authentication/SKILL.md +++ b/plugins/shutterstock/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/shutterstock/skills/python/python-calling-endpoints/SKILL.md b/plugins/shutterstock/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/shutterstock/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/shutterstock/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/shutterstock/skills/python/python-client-initialization/SKILL.md b/plugins/shutterstock/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/shutterstock/skills/python/python-client-initialization/SKILL.md +++ b/plugins/shutterstock/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/shutterstock/skills/python/python-configuration-resilience/SKILL.md b/plugins/shutterstock/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/shutterstock/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/shutterstock/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/shutterstock/skills/python/python-error-handling/SKILL.md b/plugins/shutterstock/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/shutterstock/skills/python/python-error-handling/SKILL.md +++ b/plugins/shutterstock/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/shutterstock/skills/python/python-event-streams/SKILL.md b/plugins/shutterstock/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/shutterstock/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/shutterstock/skills/python/python-file-handling/SKILL.md b/plugins/shutterstock/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/shutterstock/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/shutterstock/skills/python/python-getting-started/SKILL.md b/plugins/shutterstock/skills/python/python-getting-started/SKILL.md index 1cda83ea0..d43c95d29 100644 --- a/plugins/shutterstock/skills/python/python-getting-started/SKILL.md +++ b/plugins/shutterstock/skills/python/python-getting-started/SKILL.md @@ -5,7 +5,7 @@ description: "Shutterstock API Explorer Python SDK identity and lookup layer (Py # Getting started with the Shutterstock API Explorer Python SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Shutterstock API Explorer Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-shutterstock` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Shutterstock API Explorer Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-shutterstock-api-explorer` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated Python SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `python-client-initialization`, `python-authentication`, `python-calling-endpoints`, `python-models`, `python-error-handling`, `python-configuration-resilience` and `python-testing`. @@ -25,27 +25,32 @@ Verified against `shutterstock_api_explorer/` and `pyproject.toml` of the genera | Version | `1.2.0` | | Sync client class | `ShutterstockApiExplorerClient` (alias `Client`) | | Async client class | `AsyncShutterstockApiExplorerClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `environment` · `timeout` (default `30.0`) · `server_config` · `basic` · `customer_access_code` · `customer_access_code_token_source`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Client construction | **keyword-only**: `environment` · `timeout` (default `30.0`) · `server_config` · `retry_options` · `basic` · `customer_access_code` · `customer_access_code_token_source`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | HTTP **Basic** — set `basic` · **OAuth 2.0** authorization code — set `customer_access_code` | | Environments | 2 environments (default `"production"`) × 2 named servers, through `server_config` | | Base-URL config | `ServerConfig` (`shutterstock_api_explorer/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | -| Surface | 109 operations across 12 controllers · 155 models · 8 unions · 65 enums · 100 per-operation error unions | +| Surface | 109 operations across 12 controllers · 155 models · 8 unions · 66 enums · 100 per-operation error unions | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, import roots, the auth *pattern*, the base-URL knob), while the actual integration code comes from the companion skills. Load each one as you reach its step (see **Integration workflow** below) and confirm its types against the installed package. ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash pip install "shutterstock-api-explorer @ git+https://github.com/context-plugins/shutterstock-python-sdk.git@main" +python -c "import shutterstock_api_explorer, pathlib; print(pathlib.Path(shutterstock_api_explorer.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules @@ -158,15 +163,15 @@ The SDK map's controller table carries the same counts and links to a page per c The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `shutterstock_api_explorer/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `shutterstock_api_explorer/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `shutterstock_api_explorer/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/shutterstock-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. Every `Source` path on the map is relative to that SDK root, so `shutterstock_api_explorer/models/access_token_details.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. @@ -186,7 +191,7 @@ Read the one module that owns the fact **inside the installed package**. Locate python -c "import shutterstock_api_explorer, pathlib; print(pathlib.Path(shutterstock_api_explorer.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/shutterstock_api_explorer` on Windows, `.venv/lib/python3.*/site-packages/shutterstock_api_explorer` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/shutterstock_api_explorer` on Windows, `.venv/lib/python3.*/site-packages/shutterstock_api_explorer` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -194,7 +199,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/sh | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -204,7 +209,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/sh | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | | An operation's error mapper (status → schema) | `errors/_error.py` | @@ -221,13 +226,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The two schemes are `basic=` and `customer_access_code=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; the token is fetched lazily and cached by the client; and a **failed token fetch** raises something other than what a caller expects and **bypasses the non-raising response mode** entirely. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; **14 operations return `None`**, so `with_raw_response` is the only way to observe their status code; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`), so an unknown wire value passes through as a plain `str` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -238,8 +243,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 3. **The 14 operations that return `None`** — `audio_api.add_track_collection_items` · `audio_api.delete_track_collection` · `audio_api.delete_track_collection_items` · `audio_api.rename_track_collection` · `catalog.delete_collection` · `images.add_image_collection_items` · `images.delete_image_collection` · `images.delete_image_collection_items` · `images.rename_image_collection` · `oauth.authorize` · `videos.add_video_collection_items` · `videos.delete_video_collection` · `videos.delete_video_collection_items` · `videos.rename_video_collection`. Their raw peers are `ApiResult[None, …]`, so `with_raw_response` is the only way to observe the status code. 4. **Required vs `UNSET`** for every model member the task sets, and the fact that `Optional[T]` here is `T | UnsetType` — **not** `typing.Optional`, so `None` is not a legal value for it. 5. **The `ApiError.error` union** for each operation in scope — no operation in this SDK documents a typed error body, so `.error` is always `RawError`. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 47 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"`. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 47 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"`. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/shutterstock/skills/python/python-integrate-shutterstock/SKILL.md b/plugins/shutterstock/skills/python/python-integrate-shutterstock-api-explorer/SKILL.md similarity index 81% rename from plugins/shutterstock/skills/python/python-integrate-shutterstock/SKILL.md rename to plugins/shutterstock/skills/python/python-integrate-shutterstock-api-explorer/SKILL.md index 55b3fce64..9f02ce026 100644 --- a/plugins/shutterstock/skills/python/python-integrate-shutterstock/SKILL.md +++ b/plugins/shutterstock/skills/python/python-integrate-shutterstock-api-explorer/SKILL.md @@ -1,5 +1,5 @@ --- -name: "python-integrate-shutterstock" +name: "python-integrate-shutterstock-api-explorer" description: "MANDATORY FIRST STEP for Shutterstock API Explorer Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Shutterstock API Explorer in Python — this endpoint deletes a collection, this endpoint returns a list of all audio genres, this endpoint returns a list of all audio moods, this endpoint sets a new name for a collection, this endpoint deletes an image collection, this endpoint lists existing licenses, this endpoint licenses sounds effect assets — or when a Shutterstock API Explorer Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." --- @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `python-getting-started` is your **lookup layer - **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import shutterstock_api_explorer, pathlib; print(pathlib.Path(shutterstock_api_explorer.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import shutterstock_api_explorer, pathlib; print(pathlib.Path(shutterstock_api_explorer.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) - **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. @@ -40,6 +40,21 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** creating or activating a virtual environment, installing `shutterstock-api-explorer` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. @@ -62,8 +77,9 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. 4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `shutterstock_api_explorer.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `shutterstock_api_explorer.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. ### Step 3 — Answering pure questions diff --git a/plugins/shutterstock/skills/python/python-models/SKILL.md b/plugins/shutterstock/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/shutterstock/skills/python/python-models/SKILL.md +++ b/plugins/shutterstock/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/shutterstock/skills/python/python-models/reference.md b/plugins/shutterstock/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/shutterstock/skills/python/python-models/reference.md +++ b/plugins/shutterstock/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/shutterstock/skills/python/python-testing/SKILL.md b/plugins/shutterstock/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/shutterstock/skills/python/python-testing/SKILL.md +++ b/plugins/shutterstock/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/shutterstock/skills/typescript/typescript-authentication/SKILL.md b/plugins/shutterstock/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/shutterstock/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/shutterstock/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/shutterstock/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/shutterstock/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/shutterstock/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/shutterstock/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/shutterstock/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/shutterstock/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/shutterstock/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/shutterstock/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/shutterstock/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/shutterstock/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/shutterstock/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/shutterstock/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/shutterstock/skills/typescript/typescript-error-handling/SKILL.md b/plugins/shutterstock/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/shutterstock/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/shutterstock/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/shutterstock/skills/typescript/typescript-getting-started/SKILL.md b/plugins/shutterstock/skills/typescript/typescript-getting-started/SKILL.md index b0199378c..78af98e57 100644 --- a/plugins/shutterstock/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/shutterstock/skills/typescript/typescript-getting-started/SKILL.md @@ -5,7 +5,7 @@ description: "Shutterstock API Explorer TypeScript SDK identity and lookup layer # Getting started with the Shutterstock API Explorer TypeScript SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Shutterstock API Explorer TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-shutterstock` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Shutterstock API Explorer TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-shutterstock-api-explorer` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated TypeScript SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `typescript-client-initialization`, `typescript-authentication`, `typescript-calling-endpoints`, `typescript-models`, `typescript-error-handling`, `typescript-configuration-resilience` and `typescript-testing`. @@ -20,39 +20,41 @@ Verified against `package.json` and `sdk-map.md` of the generated package at ver | API | Shutterstock API Explorer | | Package name (what you install, and what you import) | `shutterstock-api-explorer` — **not on npm**; built from source (see *Install*) | | Import specifier | `shutterstock-api-explorer` — the package root is the **only** entry; deep imports do not resolve | +| Source repository | https://github.com/context-plugins/shutterstock-typescript-sdk (branch `main` — the ref this map documents) | | Version | `1.2.0` (API spec version `1.2.0`) | | Client class | `ShutterstockApiExplorerClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new ShutterstockApiExplorerClient(clientOptions: Partial = {})` — **every** field is optional, so `new ShutterstockApiExplorerClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `basic` · `customerAccessCode` · `customerAccessCodeStrategy`. `timeout` defaults to `60_000` ms | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new ShutterstockApiExplorerClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new ShutterstockApiExplorerClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `retry` · `fetch` · `basic` · `customerAccessCode` · `customerAccessCodeStrategy`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **HTTP Basic** — set `ClientOptions.basic`
**OAuth 2 authorization code** — set `ClientOptions.customerAccessCode` | | Environments | 2 environments (`ServerEnvironment.Production` *(default)*, `ServerEnvironment.Environment2`) × 2 server groups | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`) | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Base-URL config | `serverOptions..baseUrl` (`src/servers.ts`) | +| Log environment variable | `SHUTTERSTOCK_API_EXPLORER_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | -| Surface | 109 operations · 12 resources · 155 models · 65 open enums · 8 unions · 100 per-operation error subclasses | +| Surface | 109 operations · 12 resources · 155 models · 66 open enums · 8 unions · 100 per-operation error subclasses | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, import specifier, the auth *pattern*, the base-URL knob), while the actual integration code comes from the companion skills. Load each one as you reach its step (see **Integration workflow** below) and confirm its types against the installed package. ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/shutterstock-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 228 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 229 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { ShutterstockApiExplorerClient, ServerEnvironment, ResponseError, ShutterstockApiExplorerError } from "shutterstock-api-explorer"; +import { ShutterstockApiExplorerClient, ServerEnvironment, ApiError, ShutterstockApiExplorerError } from "shutterstock-api-explorer"; import type { ClientOptions, AccessTokenDetails } from "shutterstock-api-explorer"; ``` @@ -71,17 +73,17 @@ Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, | Group | Environment | Base URL | Override at | | --- | --- | --- | --- | -| `default` | `production` *(default)* | `https://api.shutterstock.com` | `serverOptions.default.production.baseUrl` | -| `default` | `environment2` | `https://api-sandbox.shutterstock.com` | `serverOptions.default.environment2.baseUrl` | -| `authServer` | `production` *(default)* | `https://accounts.shutterstock.com/oauth` | `serverOptions.authServer.production.baseUrl` | -| `authServer` | `environment2` | `https://accounts.shutterstock.com/oauth` | `serverOptions.authServer.environment2.baseUrl` | +| `default` | `production` *(default)* | `https://api.shutterstock.com` | `serverOptions.default.baseUrl` | +| `default` | `environment2` | `https://api-sandbox.shutterstock.com` | `serverOptions.default.baseUrl` | +| `authServer` | `production` *(default)* | `https://accounts.shutterstock.com/oauth` | `serverOptions.authServer.baseUrl` | +| `authServer` | `environment2` | `https://accounts.shutterstock.com/oauth` | `serverOptions.authServer.baseUrl` | Consequences to state on every contract sheet that touches configuration: - Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. +- An environment value the SDK does not know throws `ConfigurationError` **from the constructor** — every server group is resolved once, as the client is built — so no operation method throws synchronously. ## Auth pattern (2 schemes) @@ -105,9 +107,9 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. -**OAuth 2 fetches and caches its own token.** The token request goes through **the same client** — same `timeout`, same `fetch` — sends a form-urlencoded body, and decodes the response against a schema rather than casting it. A token is cached until shortly before it expires; a response with no `expires_in` is treated as never expiring (RFC 6749 §5.1); concurrent callers share one in-flight fetch. **A refused token endpoint rejects with `AuthError`**, wrapping the underlying `ResponseError` as `cause` — so a bad secret never looks like the business call failing, and `AuthError` is *not* a `ResponseError`. +**OAuth 2 fetches and caches its own token.** The token request goes through **the same client** — same `fetch`, same `retry` policy — sends a form-urlencoded body, and decodes the response against a schema rather than casting it. A token is cached until shortly before it expires; a response with no `expires_in` is treated as never expiring (RFC 6749 §5.1); concurrent callers share one in-flight fetch. **A refused token endpoint rejects with `AuthError`**, wrapping the underlying `ApiError` as `cause` — so a bad secret never looks like the business call failing, and `AuthError` is *not* an `ApiError`. The token request is itself a `POST` with its own timeout, retried only when `httpMethodsToRetry` names `POST`. | Flow | Token endpoint | Client credentials travel | | --- | --- | --- | @@ -138,7 +140,7 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | `client.test` | `Test` | 2 | `echo` · `validate` | | `client.oauth` | `Oauth` | 2 | `authorize` · `createAccessToken` | -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** A name that collides inside its namespace is promoted to `RequestParams` instead — no operation in this SDK takes that spelling at this version. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -146,7 +148,7 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: @@ -155,11 +157,11 @@ Locate the installed package before you rely on a lookup: node -e "console.log(require.resolve('shutterstock-api-explorer/package.json'))" ``` -Failing that it is at `node_modules/shutterstock-api-explorer/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/shutterstock-api-explorer/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/shutterstock-api-explorer/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -167,7 +169,7 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/shutterstock-api-explorer/`: @@ -177,13 +179,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -197,17 +201,23 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `SHUTTERSTOCK_API_EXPLORER_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new ShutterstockApiExplorerClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters and the OAuth 2 token cache live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) -2. **Authentication** — load **typescript-authentication** before you set credentials. The 2 schemes are `basic`, `customerAccessCode` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; the token is fetched lazily and cached on the client; a **failed token fetch** raises `AuthError`, which is not a `ResponseError` and **bypasses `.asApiResult()` entirely**; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new ShutterstockApiExplorerClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters and the OAuth 2 token cache live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) +2. **Authentication** — load **typescript-authentication** before you set credentials. The 2 schemes are `basic`, `customerAccessCode` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; the token is fetched lazily and cached on the client; a **failed token fetch** raises `AuthError`, which is not an `ApiError` and **bypasses `.asApiResult()` entirely**; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **14 operations resolve to `undefined`**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `ShutterstockApiExplorerError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `ShutterstockApiExplorerError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `SHUTTERSTOCK_API_EXPLORER_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK @@ -216,11 +226,13 @@ Beyond the usual signatures and model members, a contract sheet for the Shutters 1. **Which host each deployment talks to**, and where that is set. The members are `ServerEnvironment.Production`, `ServerEnvironment.Environment2`, defaulting to `ServerEnvironment.Production` when the field is unset. 2. **14 operations resolve to `undefined`** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 100 of 109 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. **That `Error` imported from this package is a model type, not the global of that name** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `ShutterstockApiExplorerError` for the same reason. -10. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 100 of 109 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. **That `Error` imported from this package is a model type, not the global of that name** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `ShutterstockApiExplorerError` for the same reason. +12. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/shutterstock/skills/typescript/typescript-integrate-shutterstock/SKILL.md b/plugins/shutterstock/skills/typescript/typescript-integrate-shutterstock-api-explorer/SKILL.md similarity index 54% rename from plugins/shutterstock/skills/typescript/typescript-integrate-shutterstock/SKILL.md rename to plugins/shutterstock/skills/typescript/typescript-integrate-shutterstock-api-explorer/SKILL.md index 045e17af2..fe8b0f59d 100644 --- a/plugins/shutterstock/skills/typescript/typescript-integrate-shutterstock/SKILL.md +++ b/plugins/shutterstock/skills/typescript/typescript-integrate-shutterstock-api-explorer/SKILL.md @@ -1,5 +1,5 @@ --- -name: "typescript-integrate-shutterstock" +name: "typescript-integrate-shutterstock-api-explorer" description: "MANDATORY FIRST STEP for Shutterstock API Explorer TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Shutterstock API Explorer in TypeScript/Node — add image collection items, bulk search images, create image collection, delete image collection, delete image collection items, download image, or when the Shutterstock API Explorer TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `shutterstock-api-explorer` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (shutterstock-api-explorer-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — constructing the client with no options silently selects ServerEnvironment.Production, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." --- @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/shutterstock-api-explorer/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('shutterstock-api-explorer/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('shutterstock-api-explorer/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. @@ -23,7 +23,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/shutterstock-api-explorer-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/shutterstock-api-explorer-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** @@ -40,11 +40,85 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** installing `shutterstock-api-explorer` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. @@ -63,8 +137,10 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from the **14 operations that resolve to `undefined`** (every operation whose success declares no body — the map's **Returns** bullet names them). `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `ShutterstockApiExplorerError` family, told apart by `err.kind` — and an unknown `serverEnvironment` throws `ConfigurationError` out of the client constructor, before any call exists. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `SHUTTERSTOCK_API_EXPLORER_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions @@ -74,7 +150,7 @@ A standalone Shutterstock API Explorer question with no code change: look it up - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. - **Never write any Shutterstock API Explorer or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. - **Don't web-search Shutterstock API Explorer topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Shutterstock API Explorer docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. diff --git a/plugins/shutterstock/skills/typescript/typescript-models/SKILL.md b/plugins/shutterstock/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/shutterstock/skills/typescript/typescript-models/SKILL.md +++ b/plugins/shutterstock/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/shutterstock/skills/typescript/typescript-models/reference.md b/plugins/shutterstock/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/shutterstock/skills/typescript/typescript-models/reference.md +++ b/plugins/shutterstock/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/shutterstock/skills/typescript/typescript-testing/SKILL.md b/plugins/shutterstock/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/shutterstock/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/shutterstock/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/slack/.claude-plugin/plugin.json b/plugins/slack/.claude-plugin/plugin.json index 00e57e130..b7b3bf09b 100644 --- a/plugins/slack/.claude-plugin/plugin.json +++ b/plugins/slack/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "slack", "displayName": "Slack Web API", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Slack Web API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/slack/.cursor-plugin/plugin.json b/plugins/slack/.cursor-plugin/plugin.json index 00e57e130..b7b3bf09b 100644 --- a/plugins/slack/.cursor-plugin/plugin.json +++ b/plugins/slack/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "slack", "displayName": "Slack Web API", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Slack Web API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/slack/plugin.json b/plugins/slack/plugin.json index 00e57e130..b7b3bf09b 100644 --- a/plugins/slack/plugin.json +++ b/plugins/slack/plugin.json @@ -1,7 +1,7 @@ { "name": "slack", "displayName": "Slack Web API", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Slack Web API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/slack/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/slack/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..6d0cb554e 100644 --- a/plugins/slack/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/slack/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,86 +1,27 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Slack Web API .NET SDK in C# — this API authenticates with the OAuth 2.0 authorization-code grant, and this skill gives the credential each scheme takes, the SlackWebApiClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Slack Web API .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with the OAuth 2.0 authorization-code grant. APIMatic surfaces each scheme as a **nullable credentials property** on `SlackWebApiClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) - -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). - -## Basic auth - -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` - -Sends `Authorization: Basic base64(username:password)`. - -## Bearer token - -Set the configured token property on the options class to your access-token string: - -```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; -``` - -Sends `Authorization: Bearer ACCESS_TOKEN`. - -## API key (header, query, or cookie) - -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: - -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` - -## OAuth 2.0 — client credentials (machine-to-machine) - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; - -options.{OAuthProperty} = new OAuth2ClientCredentials -{ - ClientId = "...", - ClientSecret = "...", - Scope = "..." // optional -}; -``` +| Scheme | Property on `SlackWebApiClientOptions` | Credential | +| --- | --- | --- | +| OAuth 2.0 authorization code | `SlackAuth` | `OAuth2AuthorizationCodeCredentials` | -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. +**That table is the entire authentication surface of this SDK.** `SlackWebApi.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. ## OAuth 2.0 — authorization code (3-legged, with PKCE) ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; +using SlackWebApi.Core.Authentication.OAuth2.AuthorizationCode; -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials +options.SlackAuth = new OAuth2AuthorizationCodeCredentials { ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE + ClientSecret = "...", RedirectUri = "https://app.example.com/callback", Scope = "...", // optional State = "...", // optional CSRF token @@ -94,152 +35,83 @@ options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials }; ``` -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. +The SDK sends the user to `https://slack.com/api/authorize`, exchanges the returned code for a token at `https://slack.com/api/oauth.access`, and refreshes it when it expires; if the refresh fails, it invokes `PromptForAuthorizationCode` again to re-authorize. -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: +`PromptForAuthorizationCode` runs inside the call that needs a new token, under the scheme's fetch lock, so it must not make an SDK call that uses this scheme. It counts against the per-attempt `options.Retry.Timeout`, 100 s by default, and when the call is retryable (by default a `GET`, `HEAD`, `PUT` or `OPTIONS`) a timed-out attempt is retried and prompts again. A prompt that throws anything but `OperationCanceledException` fails the call with `AuthSchemeException`. `State` is sent as you set it and never checked, so your redirect handler must verify it. -| PKCE | token request built | no secret ⇒ | -| --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | +⚠ **`ClientSecret` is required here — PKCE does not make it optional.** This SDK builds the token request with `OAuth2AuthorizationCodeStrategy.ForBasicAuthRequest`, which sends the client id and secret as HTTP Basic credentials on the exchange. Omit it and the call fails with an `AuthSchemeException` wrapping an `InvalidOperationException`, at one of two very different moments: -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. +- **PKCE disabled** (`Pkce = null`) — throws *before* `PromptForAuthorizationCode` runs. +- **PKCE enabled** (the default `S256`) — throws *after* the prompt has already run. The user completes a full browser round-trip, and only then does the exchange fail. This is the expensive one. -## OAuth 2.0 — resource owner password +The public-client carve-out — a secret-less PKCE flow — belongs to the form-body token request, which this SDK does not use. Do not plan around it here. -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; +## Token caching & refresh -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` +- Tokens are cached in-memory, **per client instance**, and reused until **30s** before expiry. +- **This grant refreshes.** The authorization-code grant is wired to `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`, so an expired access token is exchanged for a new one using the refresh token. +- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: a `401` there is an interactive re-authorization, not a silent refresh. **Plan for that in a non-interactive host** — a background worker will block on a prompt nobody can answer. +- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of a fresh token. -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. +⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** `expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to "not expired" when it is absent — so the first token is cached for the life of the client and the only thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. ### Supplying your own token source -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. +The token strategy is a public extension point: `SlackWebApiClientOptions` exposes a strategy property beside each credentials property, and the generated client falls back to the built-in strategy only when you leave it null. The strategy is used only when the credentials property beside it is also set, and `GetToken` receives that object. + +| Grant | Strategy property on `SlackWebApiClientOptions` | Type | +| --- | --- | --- | +| Authorization code | `SlackAuthTokenStrategy` | `IOAuth2RefreshableTokenStrategy` | ```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) +options.SlackAuth = new OAuth2AuthorizationCodeCredentials { /* its required members */ }; +options.SlackAuthTokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) + // Task TryRefreshToken(creds, refreshToken, ct) ``` -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: - -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). - -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. - -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test double. The per-client in-memory cache above still wraps whatever you return. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `SlackWebApiClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("SlackWebApiClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.ClientId)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "SlackWebApiClient:ClientId is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"SlackWebApiClient:ClientId is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. + +Check every value the schemes actually need. For this SDK that is: -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +| Value to validate | Feeds `SlackWebApiClientOptions` property | +| --- | --- | +| `ClientId` | `SlackAuth` | +| `ClientSecret` | `SlackAuth` | +| `RedirectUri` | `SlackAuth` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddSlackWebApiClient(options => ...)` callback when registering via DI. `SlackWebApiClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddSlackWebApiClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/slack/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/slack/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/slack/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/slack/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/slack/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/slack/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/slack/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/slack/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/slack/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/slack/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/slack/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/slack/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/slack/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/slack/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/slack/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/slack/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException
` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/slack/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/slack/skills/dotnet/dotnet-getting-started/SKILL.md index 7b62d3b27..2ef2e605e 100644 --- a/plugins/slack/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/slack/skills/dotnet/dotnet-getting-started/SKILL.md @@ -1,17 +1,17 @@ --- name: "dotnet-getting-started" -description: "Slack Web API .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-slack is the workflow that uses it." +description: "Slack Web API .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-slack-web-api is the workflow that uses it." --- # Getting started with the Slack Web API .NET SDK -> **Who this skill is for.** This is the **map layer**: load it to ground every Slack Web API SDK fact. `dotnet-integrate-slack` is the workflow that drives it — you collect the contracts for the work in scope into `slack-web-api-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. +> **Who this skill is for.** This is the **map layer**: load it to ground every Slack Web API SDK fact. `dotnet-integrate-slack-web-api` is the workflow that drives it — you collect the contracts for the work in scope into `slack-web-api-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated .NET SDK (auth, calling endpoints, models, error handling, retries, testing), see the companion API-agnostic skills: `dotnet-client-initialization`, `dotnet-authentication`, `dotnet-calling-endpoints`, `dotnet-models`, `dotnet-error-handling`, `dotnet-configuration-resilience`, `dotnet-testing`. **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity @@ -26,9 +26,9 @@ This is the **SDK-specific** entry point. For general patterns that apply to any | Auth | **OAuth 2.0** authorization code — set `SlackAuth` | | Environments | `ServerEnvironment.Production` *(default)* → `https://slack.com/api`
`ServerEnvironment.Environment2` → `https://slack.com/oauth` | | Log environment variable | `SLACKWEBAPICLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | -The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-slack` makes that binding); the **Integration workflow** below says which governs which step. +The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-slack-web-api` makes that binding); the **Integration workflow** below says which governs which step. ## Namespaces (using-directives) @@ -44,22 +44,24 @@ Models, enums, union types and the generated error types each sit in their own c ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Slack Web API, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/SlackWebApi/Models/`, `src/SlackWebApi/Errors/` and `src/SlackWebApi/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/SlackWebApi/Models/`, enums `src/SlackWebApi/Models/Enums/`, typed errors `src/SlackWebApi/Errors/`, request records `src/SlackWebApi/Requests/`, or `src/SlackWebApi/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: @@ -69,7 +71,7 @@ Keep lookups cheap — the rules that keep a session's context small: ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/slack-web-api-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/slack-web-api-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. **Clone once, up front**, into a fresh timestamped folder under `/slack-web-api-sdk-src/`, and reuse that folder for the rest of your session: @@ -87,30 +89,31 @@ git clone --filter=blob:none --branch main https://github.com/context-plugins/sl # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/slack-web-api-sdk-src/`, **never** in the project repo, and its path never goes into `slack-web-api-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `slack-web-api-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `SlackWebApiClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/SlackWebApi/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/SlackWebApi/Models/` (+ `src/SlackWebApi/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/SlackWebApi/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/SlackWebApi/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/SlackWebApi/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/SlackWebApi/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/SlackWebApi/Servers/`, `src/SlackWebApi/SlackWebApiClient.cs`, `src/SlackWebApi/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Slack Web API may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything @@ -126,17 +129,17 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/SlackWebApi/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step -You loaded every companion the sheet named before starting (per `dotnet-integrate-slack`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: +You loaded every companion the sheet named before starting (per `dotnet-integrate-slack-web-api`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: 1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new SlackWebApiClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) 2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `SlackWebApiClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/slack/skills/dotnet/dotnet-integrate-slack/SKILL.md b/plugins/slack/skills/dotnet/dotnet-integrate-slack-web-api/SKILL.md similarity index 66% rename from plugins/slack/skills/dotnet/dotnet-integrate-slack/SKILL.md rename to plugins/slack/skills/dotnet/dotnet-integrate-slack-web-api/SKILL.md index 8daafd364..03370e86c 100644 --- a/plugins/slack/skills/dotnet/dotnet-integrate-slack/SKILL.md +++ b/plugins/slack/skills/dotnet/dotnet-integrate-slack-web-api/SKILL.md @@ -1,125 +1,173 @@ ---- -name: "dotnet-integrate-slack" -description: "MANDATORY FIRST STEP for Slack Web API .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Slack Web API in C# — Admin, AdminApps, AdminAppsApproved, AdminAppsRequests, AdminAppsRestricted, AdminConversations, AdminConversationsEkm, AdminConversationsRestrictAccess, AdminEmoji, AdminInviteRequests, AdminInviteRequestsApproved, AdminInviteRequestsDenied, AdminTeams, AdminTeamsAdmins, AdminTeamsOwners, AdminTeamsSettings, AdminUsergroups, AdminUsers, AdminUsersSession, ApiApi, Apps, AppsEventAuthorizations, AppsPermissions, AppsPermissionsResources, AppsPermissionsScopes, AppsPermissionsUsers, Auth, Bots, Calls, CallsParticipants, Chat, ChatScheduledMessages, Conversations, Dialog, Dnd, Emoji, Files, FilesComments, FilesRemote, Migration, Oauth, OauthV2, Pins, Reactions, Reminders, Rtm, Search, Stars, TeamApi, TeamProfile, Usergroups, UsergroupsUsers, Users, UsersProfile, Views, Workflows — or when a Slack Web API .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file slack-web-api-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." ---- - -# Slack Web API .NET SDK — Workflow (map + skills) - -You are the Slack Web API .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. - -**Scope guard:** the APIMatic-generated Slack Web API **.NET SDK** (root namespace `SlackWebApi`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. - -## Grounding rules - -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `slack-web-api-plan.md` — the plan must stay portable. -- **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. -- **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. -- **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. -- **The sheet records Slack Web API's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. -- **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. - -## Workflow - -**If the user opens with a reported SDK error or unexpected Slack Web API behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: - -### Step 1 — Plan first: write `slack-web-api-plan.md` (always, for any implementation work) - -Your FIRST deliverable is `slack-web-api-plan.md` at `/slack-web-api-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. - -#### How to ground (map-first, one pass) - -1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. -3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. -4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. -5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. - -#### Prerequisites you can run alongside - -These need no SDK knowledge and touch no project file, so do them before or alongside the map work: - -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); -- the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; -- locating the SDK reference and its version in the project; -- credentials/environment verification (per the task's secret-handling rules); -- setting up your task tracking. - -Never use this prerequisite work as a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. - -#### HARD GATE - -**No project-file creation or edits until `slack-web-api-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. - -#### The `slack-web-api-plan.md` format (keep it tight — tables, not prose) - -1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. -3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. -6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. -7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. - -| # | Concern | The decision the plan must record | -| --- | --- | --- | -| 1 | **Credential fail-fast** | Where credentials are bound, and that the host refuses to start when one is missing or blank — rather than discovering it as a 401 on the first call in production. A multi-part credential needs **every** part checked, because a blank part is not a missing one. | -| 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | -| 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | -| 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | -| 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | -| 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | -| 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | - -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. - -Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. - -### Step 2 — Required reading (do this before you write any code) - -The contract sheet ends with a **REQUIRED READING** block, and its rows carry inline `MUST load ` pointers. **Load every `dotnet-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. If the sheet names none, load `dotnet-error-handling` anyway — every integration writes an error boundary. - -These are API-agnostic usage skills; loading them is not the same as reading the map. Contract *facts* still come only from the sheet or a map lookup. - -Before implementing, check the plan's **Assumptions & Blockers** section: - -- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `slack-web-api-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. -- Minor assumptions only → proceed. - -Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. - -### Step 3 — Implement from the contract sheet - -1. Read `slack-web-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. -2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. -3. After every change: `dotnet build`; fix non-SDK errors yourself. -4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `SlackWebApi.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. - -### Step 4 — Fixing SDK errors (map-first, in place) - -A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `SlackWebApi.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. - -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. -3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". -4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). -5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. -6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. - -If a fix corrects a row in `slack-web-api-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. - -### Step 5 — Pure questions - -A standalone Slack Web API question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. - -## Anti-patterns — never do these - -- **Never write a Slack Web API/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** -- **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. -- **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. -- **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search Slack Web API for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `SlackWebApi` is what the generated source declares, not what public docs describe. -- **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. - +--- +name: "dotnet-integrate-slack-web-api" +description: "MANDATORY FIRST STEP for Slack Web API .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Slack Web API in C# — Admin, AdminApps, AdminAppsApproved, AdminAppsRequests, AdminAppsRestricted, AdminConversations, AdminConversationsEkm, AdminConversationsRestrictAccess, AdminEmoji, AdminInviteRequests, AdminInviteRequestsApproved, AdminInviteRequestsDenied, AdminTeams, AdminTeamsAdmins, AdminTeamsOwners, AdminTeamsSettings, AdminUsergroups, AdminUsers, AdminUsersSession, ApiApi, Apps, AppsEventAuthorizations, AppsPermissions, AppsPermissionsResources, AppsPermissionsScopes, AppsPermissionsUsers, Auth, Bots, Calls, CallsParticipants, Chat, ChatScheduledMessages, Conversations, Dialog, Dnd, Emoji, Files, FilesComments, FilesRemote, Migration, Oauth, OauthV2, Pins, Reactions, Reminders, Rtm, Search, Stars, TeamApi, TeamProfile, Usergroups, UsergroupsUsers, Users, UsersProfile, Views, Workflows — or when a Slack Web API .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file slack-web-api-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." +--- + +# Slack Web API .NET SDK — Workflow (map + skills) + +You are the Slack Web API .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. + +**Scope guard:** the APIMatic-generated Slack Web API **.NET SDK** (root namespace `SlackWebApi`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. + +## Grounding rules + +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `slack-web-api-plan.md` never carries its path — the plan must stay portable. +- **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. +- **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. +- **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. +- **The sheet records Slack Web API's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. +- **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. + +## Workflow + +**If the user opens with a reported SDK error or unexpected Slack Web API behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: + +### Step 1 — Plan first: write `slack-web-api-plan.md` (always, for any implementation work) + +Your FIRST deliverable is `slack-web-api-plan.md` at `/slack-web-api-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. + +#### How to ground (map-first, one pass) + +1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. +3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. +4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. +5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. + +#### Prerequisites you can run alongside + +These need no SDK knowledge and touch no project file, so do them before or alongside the map work: + +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; +- `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; +- locating the SDK reference and its version in the project; +- credentials/environment verification (per the task's secret-handling rules); +- setting up your task tracking. + +Never use this prerequisite work as a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. + +#### HARD GATE + +**No project-file creation or edits until `slack-web-api-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. + +#### The `slack-web-api-plan.md` format (keep it tight — tables, not prose) + +1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + +3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. +7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. + +| # | Concern | The decision the plan must record | +| --- | --- | --- | +| 1 | **Credential fail-fast** | Where credentials are bound, and that the host refuses to start when one is missing or blank — rather than discovering it as a 401 on the first call in production. A multi-part credential needs **every** part checked, because a blank part is not a missing one. | +| 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | +| 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | +| 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | +| 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | +| 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. + +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. + +Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. + +### Step 2 — Required reading (do this before you write any code) + +The contract sheet ends with a **REQUIRED READING** block, and its rows carry inline `MUST load ` pointers. **Load every `dotnet-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. If the sheet names none, load `dotnet-error-handling` anyway — every integration writes an error boundary. + +These are API-agnostic usage skills; loading them is not the same as reading the map. Contract *facts* still come only from the sheet or a map lookup. + +Before implementing, check the plan's **Assumptions & Blockers** section: + +- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `slack-web-api-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. +- Minor assumptions only → proceed. + +Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. + +### Step 3 — Implement from the contract sheet + +1. Read `slack-web-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. +2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. +3. After every change: `dotnet build`; fix non-SDK errors yourself. +4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `SlackWebApi.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. + +### Step 4 — Fixing SDK errors (map-first, in place) + +A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `SlackWebApi.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. + +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/SlackWebApi/Models/{Type}.cs`, `src/SlackWebApi/Models/Enums/{Type}.cs`, `src/SlackWebApi/Errors/{Type}.cs`, `src/SlackWebApi/Requests/{Type}.cs`, or `src/SlackWebApi/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. +3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". +4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). +5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. +6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. + +If a fix corrects a row in `slack-web-api-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. + +### Step 5 — Pure questions + +A standalone Slack Web API question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. + +## Anti-patterns — never do these + +- **Never write a Slack Web API/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** +- **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. +- **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. +- **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. +- **Do not web-search Slack Web API for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `SlackWebApi` is what the generated source declares, not what public docs describe. +- **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. + diff --git a/plugins/slack/skills/dotnet/dotnet-models/SKILL.md b/plugins/slack/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/slack/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/slack/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/slack/skills/dotnet/dotnet-models/reference.md b/plugins/slack/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/slack/skills/dotnet/dotnet-models/reference.md +++ b/plugins/slack/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/slack/skills/dotnet/dotnet-testing/SKILL.md b/plugins/slack/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/slack/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/slack/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/slack/skills/python/python-authentication/SKILL.md b/plugins/slack/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/slack/skills/python/python-authentication/SKILL.md +++ b/plugins/slack/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/slack/skills/python/python-calling-endpoints/SKILL.md b/plugins/slack/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/slack/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/slack/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/slack/skills/python/python-client-initialization/SKILL.md b/plugins/slack/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/slack/skills/python/python-client-initialization/SKILL.md +++ b/plugins/slack/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/slack/skills/python/python-configuration-resilience/SKILL.md b/plugins/slack/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/slack/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/slack/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/slack/skills/python/python-error-handling/SKILL.md b/plugins/slack/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/slack/skills/python/python-error-handling/SKILL.md +++ b/plugins/slack/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/slack/skills/python/python-event-streams/SKILL.md b/plugins/slack/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/slack/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/slack/skills/python/python-file-handling/SKILL.md b/plugins/slack/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/slack/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/slack/skills/python/python-getting-started/SKILL.md b/plugins/slack/skills/python/python-getting-started/SKILL.md index c8935f8f4..be3c5f921 100644 --- a/plugins/slack/skills/python/python-getting-started/SKILL.md +++ b/plugins/slack/skills/python/python-getting-started/SKILL.md @@ -5,7 +5,7 @@ description: "Slack Web API Python SDK identity and lookup layer (Python only) # Getting started with the Slack Web API Python SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Slack Web API Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-slack` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Slack Web API Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-slack-web-api` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated Python SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `python-client-initialization`, `python-authentication`, `python-calling-endpoints`, `python-models`, `python-error-handling`, `python-configuration-resilience` and `python-testing`. @@ -25,12 +25,12 @@ Verified against `slack_web_api/` and `pyproject.toml` of the generated package | Version | `1.7.0` | | Sync client class | `SlackWebApiClient` (alias `Client`) | | Async client class | `AsyncSlackWebApiClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `environment` · `base_url` · `timeout` (default `30.0`) · `slack_auth` · `slack_auth_token_source`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Client construction | **keyword-only**: `environment` · `base_url` · `timeout` (default `30.0`) · `retry_options` · `slack_auth` · `slack_auth_token_source`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | **OAuth 2.0** authorization code — set `slack_auth` | | Environments | 2 environments selected by `environment` (default `"production"`), overridable with `base_url` | | Base-URL config | `ServerConfig` (`slack_web_api/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | | Surface | 253 operations across 56 controllers · 357 models · 0 unions · 89 enums · 0 per-operation error unions | @@ -39,13 +39,18 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash pip install "slack-web-api @ git+https://github.com/context-plugins/slack-python-sdk.git@main" +python -c "import slack_web_api, pathlib; print(pathlib.Path(slack_web_api.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules @@ -183,15 +188,15 @@ The SDK map's controller table carries the same counts and links to a page per c The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `slack_web_api/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `slack_web_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `slack_web_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/slack-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. Every `Source` path on the map is relative to that SDK root, so `slack_web_api/models/apimethodusers_get_presence.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. @@ -211,7 +216,7 @@ Read the one module that owns the fact **inside the installed package**. Locate python -c "import slack_web_api, pathlib; print(pathlib.Path(slack_web_api.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/slack_web_api` on Windows, `.venv/lib/python3.*/site-packages/slack_web_api` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/slack_web_api` on Windows, `.venv/lib/python3.*/site-packages/slack_web_api` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -219,7 +224,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/sl | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -229,7 +234,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/sl | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | @@ -245,13 +250,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The one scheme is `slack_auth=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; the token is fetched lazily and cached by the client; and a **failed token fetch** raises something other than what a caller expects and **bypasses the non-raising response mode** entirely. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`), so an unknown wire value passes through as a plain `str` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -262,8 +267,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 3. **Every operation returns a payload** — no operation in this SDK returns `None`, so the `with_raw_response` peer is needed only where the status code itself matters. 4. **Required vs `UNSET`** for every model member the task sets, and the fact that `Optional[T]` here is `T | UnsetType` — **not** `typing.Optional`, so `None` is not a legal value for it. 5. **The `ApiError.error` union** for each operation in scope — no operation in this SDK documents a typed error body, so `.error` is always `RawError`. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 72 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"` of the 2 declared. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 250 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"` of the 2 declared. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/slack/skills/python/python-integrate-slack/SKILL.md b/plugins/slack/skills/python/python-integrate-slack-web-api/SKILL.md similarity index 81% rename from plugins/slack/skills/python/python-integrate-slack/SKILL.md rename to plugins/slack/skills/python/python-integrate-slack-web-api/SKILL.md index 200d856b2..2179585a2 100644 --- a/plugins/slack/skills/python/python-integrate-slack/SKILL.md +++ b/plugins/slack/skills/python/python-integrate-slack-web-api/SKILL.md @@ -1,81 +1,97 @@ ---- -name: "python-integrate-slack" -description: "MANDATORY FIRST STEP for Slack Web API Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Slack Web API in Python — approve an app for installation on a workspace, list approved apps for an org or workspace, list app requests for a team/workspace, restrict an app for installation on a workspace, list restricted apps for an org or workspace, archive a public or private channel, convert a public channel to a private channel, delete a public or private channel — or when a Slack Web API Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." ---- - -# Slack Web API Python SDK — Integration workflow (lookup layer + contract sheet) - -You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. - -## Your lookup layer - -- **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. -- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import slack_web_api, pathlib; print(pathlib.Path(slack_web_api.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) -- **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. -- **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. - -**Scope guard:** the APIMatic-generated Slack Web API **Python SDK** (import root `slack_web_api`, distribution `slack-web-api`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. - -## Workflow - -**If the user opens with a reported SDK error or unexpected Slack Web API behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: - -### Step 1 — Plan first (always, for any implementation work) - -Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/slack-web-api-plan.md` — that absolute path, not a location you pick later. - -**Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - -- the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `slack-web-api` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; -- a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — including which host `environment` will select, since omitting it is silent; -- **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; -- setting up your task tracking. - -Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. - -**HARD GATE — no project-file creation or edits until:** `slack-web-api-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. - -**What the gate does not cover:** creating or activating a virtual environment, installing `slack-web-api` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. - -### Step 1c — Required reading (do this before you write any code) - -End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. - -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `python-error-handling` always (every integration writes an error boundary); `python-client-initialization` before the client is constructed; `python-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes the transport. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. - -These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a module in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. - -Before implementing, check the plan's **Assumptions & Blockers** section: - -- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `slack-web-api-plan.md` in place. -- Minor assumptions only → proceed. - -Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. - -### Step 2 — Implement from the contract sheet - -1. Read `slack-web-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). -3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. -4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. -5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `slack_web_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. - -### Step 3 — Answering pure questions - -A standalone Slack Web API question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. - -## Anti-patterns — never do these - -- **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. -- **Never write a Slack Web API/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Don't web-search Slack Web API topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Slack Web API docs describe the REST API, not this SDK's generated surface, and the two disagree on names. -- **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. -- **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. -- **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. -- **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. - +--- +name: "python-integrate-slack-web-api" +description: "MANDATORY FIRST STEP for Slack Web API Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Slack Web API in Python — approve an app for installation on a workspace, list approved apps for an org or workspace, list app requests for a team/workspace, restrict an app for installation on a workspace, list restricted apps for an org or workspace, archive a public or private channel, convert a public channel to a private channel, delete a public or private channel — or when a Slack Web API Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." +--- + +# Slack Web API Python SDK — Integration workflow (lookup layer + contract sheet) + +You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. + +## Your lookup layer + +- **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. +- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import slack_web_api, pathlib; print(pathlib.Path(slack_web_api.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. +- **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. + +**Scope guard:** the APIMatic-generated Slack Web API **Python SDK** (import root `slack_web_api`, distribution `slack-web-api`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. + +## Workflow + +**If the user opens with a reported SDK error or unexpected Slack Web API behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: + +### Step 1 — Plan first (always, for any implementation work) + +Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/slack-web-api-plan.md` — that absolute path, not a location you pick later. + +**Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** + +- the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; +- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `slack-web-api` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; +- a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; +- credentials/environment verification (per the task's secret-handling rules) — including which host `environment` will select, since omitting it is silent; +- **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; +- setting up your task tracking. + +Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. + +**HARD GATE — no project-file creation or edits until:** `slack-web-api-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. + +**What the gate does not cover:** creating or activating a virtual environment, installing `slack-web-api` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +### Step 1c — Required reading (do this before you write any code) + +End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. + +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `python-error-handling` always (every integration writes an error boundary); `python-client-initialization` before the client is constructed; `python-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes the transport. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. + +These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a module in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. + +Before implementing, check the plan's **Assumptions & Blockers** section: + +- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `slack-web-api-plan.md` in place. +- Minor assumptions only → proceed. + +Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. + +### Step 2 — Implement from the contract sheet + +1. Read `slack-web-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. +2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). +3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. +4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. +5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `slack_web_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. + +### Step 3 — Answering pure questions + +A standalone Slack Web API question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. + +## Anti-patterns — never do these + +- **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. +- **Never write a Slack Web API/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** +- **Don't web-search Slack Web API topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Slack Web API docs describe the REST API, not this SDK's generated surface, and the two disagree on names. +- **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. +- **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. +- **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. +- **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. + diff --git a/plugins/slack/skills/python/python-models/SKILL.md b/plugins/slack/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/slack/skills/python/python-models/SKILL.md +++ b/plugins/slack/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/slack/skills/python/python-models/reference.md b/plugins/slack/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/slack/skills/python/python-models/reference.md +++ b/plugins/slack/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/slack/skills/python/python-testing/SKILL.md b/plugins/slack/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/slack/skills/python/python-testing/SKILL.md +++ b/plugins/slack/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/slack/skills/typescript/typescript-authentication/SKILL.md b/plugins/slack/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/slack/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/slack/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/slack/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/slack/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/slack/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/slack/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/slack/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/slack/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/slack/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/slack/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/slack/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/slack/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/slack/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/slack/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/slack/skills/typescript/typescript-error-handling/SKILL.md b/plugins/slack/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/slack/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/slack/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/slack/skills/typescript/typescript-getting-started/SKILL.md b/plugins/slack/skills/typescript/typescript-getting-started/SKILL.md index 9c893e537..e3d4019d2 100644 --- a/plugins/slack/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/slack/skills/typescript/typescript-getting-started/SKILL.md @@ -5,7 +5,7 @@ description: "Slack Web API TypeScript SDK identity and lookup layer (TypeScript # Getting started with the Slack Web API TypeScript SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Slack Web API TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-slack` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Slack Web API TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-slack-web-api` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated TypeScript SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `typescript-client-initialization`, `typescript-authentication`, `typescript-calling-endpoints`, `typescript-models`, `typescript-error-handling`, `typescript-configuration-resilience` and `typescript-testing`. @@ -20,15 +20,17 @@ Verified against `package.json` and `sdk-map.md` of the generated package at ver | API | Slack Web API | | Package name (what you install, and what you import) | `slack-web-api` — **not on npm**; built from source (see *Install*) | | Import specifier | `slack-web-api` — the package root is the **only** entry; deep imports do not resolve | +| Source repository | https://github.com/context-plugins/slack-typescript-sdk (branch `main` — the ref this map documents) | | Version | `1.7.0` (API spec version `1.7.0`) | | Client class | `SlackWebApiClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new SlackWebApiClient(clientOptions: Partial = {})` — **every** field is optional, so `new SlackWebApiClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `slackAuth` · `slackAuthStrategy`. `timeout` defaults to `60_000` ms | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new SlackWebApiClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new SlackWebApiClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `retry` · `fetch` · `slackAuth` · `slackAuthStrategy`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **OAuth 2 authorization code** — set `ClientOptions.slackAuth` | | Environments | 2 environments (`ServerEnvironment.Production` *(default)*, `ServerEnvironment.Environment2`) × 1 server group | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`), defaulting to `https://slack.com/api` | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Base-URL config | `serverOptions.baseUrl` (`src/servers.ts`), defaulting to `https://slack.com/api` | +| Log environment variable | `SLACK_WEB_API_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Surface | 253 operations · 56 resources · 357 models · 89 open enums · 253 per-operation error subclasses | @@ -37,22 +39,22 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/slack-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 446 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 446 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { SlackWebApiClient, ServerEnvironment, ResponseError, SlackWebApiError } from "slack-web-api"; +import { SlackWebApiClient, ServerEnvironment, ApiError, SlackWebApiError } from "slack-web-api"; import type { ClientOptions, ApImethodusersGetPresence } from "slack-web-api"; ``` @@ -70,15 +72,15 @@ Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, | Group | Environment | Base URL | Override at | | --- | --- | --- | --- | -| `default` | `production` *(default)* | `https://slack.com/api` | `serverOptions.default.production.baseUrl` | -| `default` | `environment2` | `https://slack.com/oauth` | `serverOptions.default.environment2.baseUrl` | +| `default` | `production` *(default)* | `https://slack.com/api` | `serverOptions.baseUrl` | +| `default` | `environment2` | `https://slack.com/oauth` | `serverOptions.baseUrl` | Consequences to state on every contract sheet that touches configuration: - Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. +- An environment value the SDK does not know throws `ConfigurationError` **from the constructor** — every server group is resolved once, as the client is built — so no operation method throws synchronously. ## Auth pattern (1 scheme) @@ -100,9 +102,9 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. -**OAuth 2 fetches and caches its own token.** The token request goes through **the same client** — same `timeout`, same `fetch` — sends a form-urlencoded body, and decodes the response against a schema rather than casting it. A token is cached until shortly before it expires; a response with no `expires_in` is treated as never expiring (RFC 6749 §5.1); concurrent callers share one in-flight fetch. **A refused token endpoint rejects with `AuthError`**, wrapping the underlying `ResponseError` as `cause` — so a bad secret never looks like the business call failing, and `AuthError` is *not* a `ResponseError`. +**OAuth 2 fetches and caches its own token.** The token request goes through **the same client** — same `fetch`, same `retry` policy — sends a form-urlencoded body, and decodes the response against a schema rather than casting it. A token is cached until shortly before it expires; a response with no `expires_in` is treated as never expiring (RFC 6749 §5.1); concurrent callers share one in-flight fetch. **A refused token endpoint rejects with `AuthError`**, wrapping the underlying `ApiError` as `cause` — so a bad secret never looks like the business call failing, and `AuthError` is *not* an `ApiError`. The token request is itself a `POST` with its own timeout, retried only when `httpMethodsToRetry` names `POST`. | Flow | Token endpoint | Client credentials travel | | --- | --- | --- | @@ -177,7 +179,7 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | `client.views` | `Views` | 4 | `viewsOpen` · `viewsPublish` · `viewsPush` · `viewsUpdate` | | `client.workflows` | `Workflows` | 3 | `workflowsStepCompleted` · `workflowsStepFailed` · `workflowsUpdateStep` | -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** A name that collides inside its namespace is promoted to `RequestParams` instead — no operation in this SDK takes that spelling at this version. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -185,7 +187,7 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: @@ -194,11 +196,11 @@ Locate the installed package before you rely on a lookup: node -e "console.log(require.resolve('slack-web-api/package.json'))" ``` -Failing that it is at `node_modules/slack-web-api/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/slack-web-api/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/slack-web-api/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -206,7 +208,7 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/slack-web-api/`: @@ -216,13 +218,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -236,17 +240,23 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `SLACK_WEB_API_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new SlackWebApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters and the OAuth 2 token cache live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) -2. **Authentication** — load **typescript-authentication** before you set credentials. The scheme is `slackAuth` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; the token is fetched lazily and cached on the client; a **failed token fetch** raises `AuthError`, which is not a `ResponseError` and **bypasses `.asApiResult()` entirely**; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new SlackWebApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters and the OAuth 2 token cache live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) +2. **Authentication** — load **typescript-authentication** before you set credentials. The scheme is `slackAuth` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; the token is fetched lazily and cached on the client; a **failed token fetch** raises `AuthError`, which is not an `ApiError` and **bypasses `.asApiResult()` entirely**; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **no operation resolves to `undefined` — every one of them carries a body**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `SlackWebApiError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `SlackWebApiError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `SLACK_WEB_API_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK @@ -255,10 +265,12 @@ Beyond the usual signatures and model members, a contract sheet for the Slack We 1. **Which host each deployment talks to**, and where that is set. The members are `ServerEnvironment.Production`, `ServerEnvironment.Environment2`, defaulting to `ServerEnvironment.Production` when the field is unset. 2. **no operation resolves to `undefined` — every one of them carries a body** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 253 of 253 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 253 of 253 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/slack/skills/typescript/typescript-integrate-slack/SKILL.md b/plugins/slack/skills/typescript/typescript-integrate-slack-web-api/SKILL.md similarity index 54% rename from plugins/slack/skills/typescript/typescript-integrate-slack/SKILL.md rename to plugins/slack/skills/typescript/typescript-integrate-slack-web-api/SKILL.md index 7564bdc98..87ba0654f 100644 --- a/plugins/slack/skills/typescript/typescript-integrate-slack/SKILL.md +++ b/plugins/slack/skills/typescript/typescript-integrate-slack-web-api/SKILL.md @@ -1,5 +1,5 @@ --- -name: "typescript-integrate-slack" +name: "typescript-integrate-slack-web-api" description: "MANDATORY FIRST STEP for Slack Web API TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Slack Web API in TypeScript/Node — admin apps approve, admin apps restrict, admin apps approved list, admin apps requests list, admin apps restricted list, admin conversations archive, or when the Slack Web API TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `slack-web-api` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (slack-web-api-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — constructing the client with no options silently selects ServerEnvironment.Production, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." --- @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/slack-web-api/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('slack-web-api/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('slack-web-api/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. @@ -23,7 +23,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/slack-web-api-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/slack-web-api-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** @@ -40,11 +40,85 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** installing `slack-web-api` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. @@ -63,8 +137,10 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from an operation whose success carries no body. `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `SlackWebApiError` family, told apart by `err.kind` — and an unknown `serverEnvironment` throws `ConfigurationError` out of the client constructor, before any call exists. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `SLACK_WEB_API_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions @@ -74,7 +150,7 @@ A standalone Slack Web API question with no code change: look it up in `typescri - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. - **Never write any Slack Web API or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. - **Don't web-search Slack Web API topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Slack Web API docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. diff --git a/plugins/slack/skills/typescript/typescript-models/SKILL.md b/plugins/slack/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/slack/skills/typescript/typescript-models/SKILL.md +++ b/plugins/slack/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/slack/skills/typescript/typescript-models/reference.md b/plugins/slack/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/slack/skills/typescript/typescript-models/reference.md +++ b/plugins/slack/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/slack/skills/typescript/typescript-testing/SKILL.md b/plugins/slack/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/slack/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/slack/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/sportsdata/.claude-plugin/plugin.json b/plugins/sportsdata/.claude-plugin/plugin.json index dd855a04b..a9d6ec485 100644 --- a/plugins/sportsdata/.claude-plugin/plugin.json +++ b/plugins/sportsdata/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sportsdata", "displayName": "SportsDataIO", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated SportsDataIO SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/sportsdata/.cursor-plugin/plugin.json b/plugins/sportsdata/.cursor-plugin/plugin.json index dd855a04b..a9d6ec485 100644 --- a/plugins/sportsdata/.cursor-plugin/plugin.json +++ b/plugins/sportsdata/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sportsdata", "displayName": "SportsDataIO", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated SportsDataIO SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/sportsdata/plugin.json b/plugins/sportsdata/plugin.json index dd855a04b..a9d6ec485 100644 --- a/plugins/sportsdata/plugin.json +++ b/plugins/sportsdata/plugin.json @@ -1,7 +1,7 @@ { "name": "sportsdata", "displayName": "SportsDataIO", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated SportsDataIO SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/sportsdata/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/sportsdata/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..03ec24a57 100644 --- a/plugins/sportsdata/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/sportsdata/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,245 +1,83 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the SportsDataIO .NET SDK in C# — this API authenticates with an API key in a header and an API key in a query parameter, and this skill gives the credential each scheme takes, the SportsDataIoClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the SportsDataIO .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with an API key in a header and an API key in a query parameter. APIMatic surfaces each scheme as a **nullable credentials property** on `SportsDataIoClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) - -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). - -## Basic auth - -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` - -Sends `Authorization: Basic base64(username:password)`. - -## Bearer token - -Set the configured token property on the options class to your access-token string: - -```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; -``` - -Sends `Authorization: Bearer ACCESS_TOKEN`. - -## API key (header, query, or cookie) - -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: - -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` +| Scheme | Property on `SportsDataIoClientOptions` | Credential | +| --- | --- | --- | +| API key — `Ocp-Apim-Subscription-Key` header | `ApiKeyHeader` | `string` | +| API key — `key` query parameter | `ApiKeyQuery` | `string` | -## OAuth 2.0 — client credentials (machine-to-machine) +**That table is the entire authentication surface of this SDK.** `SportsDataIo.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; - -options.{OAuthProperty} = new OAuth2ClientCredentials -{ - ClientId = "...", - ClientSecret = "...", - Scope = "..." // optional -}; -``` +## API key (header) -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. - -## OAuth 2.0 — authorization code (3-legged, with PKCE) +The key travels in the `Ocp-Apim-Subscription-Key` request header. The header name is fixed by the generated scheme — you supply only the value: ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; - -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials -{ - ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE - RedirectUri = "https://app.example.com/callback", - Scope = "...", // optional - State = "...", // optional CSRF token - Pkce = PkceMethod.S256, // default; RFC 7636 - PromptForAuthorizationCode = async (authorizationUrl, ct) => - { - // Open/redirect the browser to authorizationUrl, then return the - // authorization code your redirect endpoint received. - return await GetCodeFromUserAsync(authorizationUrl, ct); - } -}; +options.ApiKeyHeader = "API_KEY"; ``` -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. - -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: - -| PKCE | token request built | no secret ⇒ | -| --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | - -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. - -## OAuth 2.0 — resource owner password +## API key (query parameter) -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; - -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` - -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. - -### Supplying your own token source - -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. +The key is appended to every request as the `key` query parameter. The parameter name is fixed by the generated scheme — you supply only the value: ```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) +options.ApiKeyQuery = "API_KEY"; ``` -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: +⚠ **A query-string credential is logged by everything on the path.** Proxies, CDNs, load balancers and the provider's own access logs record full URLs, and this SDK's request logging is no exception. Treat the key as exposed to your infrastructure, and rotate it on that basis. -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). +## Combining the schemes -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. +This SDK generates more than one credentials property, and its operations compose them. You configure the composition by setting the relevant properties on `SportsDataIoClientOptions` — the generated client wires the rest. -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +**OR (`AuthSchemeAny`) — alternatives, tried in order.** Schemes with **no credentials configured are skipped, not tried**; the first configured scheme that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails. Configure exactly the one you intend to use — an extra credential set "just in case" changes which scheme authenticates the call. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `SportsDataIoClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("SportsDataIoClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.ApiKeyHeader)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "SportsDataIoClient:ApiKeyHeader is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"SportsDataIoClient:ApiKeyHeader is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +Check every value **the scheme you chose** needs. The rows below are not one checklist: the schemes above are alternatives, so a row belonging to a scheme you are not using needs no value at all, and demanding one at startup blocks a boot that would have worked. Validate the rows whose property you set, and no others: + +| Value to validate | Feeds `SportsDataIoClientOptions` property | +| --- | --- | +| `ApiKeyHeader` | `ApiKeyHeader` | +| `ApiKeyQuery` | `ApiKeyQuery` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddSportsDataIoClient(options => ...)` callback when registering via DI. `SportsDataIoClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddSportsDataIoClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/sportsdata/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/sportsdata/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/sportsdata/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/sportsdata/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/sportsdata/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/sportsdata/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/sportsdata/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/sportsdata/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/sportsdata/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/sportsdata/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/sportsdata/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/sportsdata/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/sportsdata/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/sportsdata/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/sportsdata/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/sportsdata/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/sportsdata/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/sportsdata/skills/dotnet/dotnet-getting-started/SKILL.md index d468f759d..92fca4d8b 100644 --- a/plugins/sportsdata/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/sportsdata/skills/dotnet/dotnet-getting-started/SKILL.md @@ -1,17 +1,17 @@ --- name: "dotnet-getting-started" -description: "SportsDataIO .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-sportsdata is the workflow that uses it." +description: "SportsDataIO .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-sportsdataio is the workflow that uses it." --- # Getting started with the SportsDataIO .NET SDK -> **Who this skill is for.** This is the **map layer**: load it to ground every SportsDataIO SDK fact. `dotnet-integrate-sportsdata` is the workflow that drives it — you collect the contracts for the work in scope into `sports-data-io-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. +> **Who this skill is for.** This is the **map layer**: load it to ground every SportsDataIO SDK fact. `dotnet-integrate-sportsdataio` is the workflow that drives it — you collect the contracts for the work in scope into `sports-data-io-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated .NET SDK (auth, calling endpoints, models, error handling, retries, testing), see the companion API-agnostic skills: `dotnet-client-initialization`, `dotnet-authentication`, `dotnet-calling-endpoints`, `dotnet-models`, `dotnet-error-handling`, `dotnet-configuration-resilience`, `dotnet-testing`. **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity @@ -26,9 +26,9 @@ This is the **SDK-specific** entry point. For general patterns that apply to any | Auth | **API key** in the `Ocp-Apim-Subscription-Key` header — set `ApiKeyHeader`
**API key** in the `key` query parameter — set `ApiKeyQuery` | | Environments | `ServerEnvironment.Production` *(default)* → `https://api.sportsdata.io` | | Log environment variable | `SPORTSDATAIOCLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | -The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-sportsdata` makes that binding); the **Integration workflow** below says which governs which step. +The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-sportsdataio` makes that binding); the **Integration workflow** below says which governs which step. ## Namespaces (using-directives) @@ -44,22 +44,24 @@ Models, enums, union types and the generated error types each sit in their own c ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call SportsDataIO, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/SportsDataIo/Models/`, `src/SportsDataIo/Errors/` and `src/SportsDataIo/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/SportsDataIo/Models/`, enums `src/SportsDataIo/Models/Enums/`, typed errors `src/SportsDataIo/Errors/`, request records `src/SportsDataIo/Requests/`, or `src/SportsDataIo/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: @@ -69,7 +71,7 @@ Keep lookups cheap — the rules that keep a session's context small: ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/sports-data-io-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/sports-data-io-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. **Clone once, up front**, into a fresh timestamped folder under `/sports-data-io-sdk-src/`, and reuse that folder for the rest of your session: @@ -87,30 +89,31 @@ git clone --filter=blob:none --branch main https://github.com/context-plugins/sp # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/sports-data-io-sdk-src/`, **never** in the project repo, and its path never goes into `sports-data-io-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `sports-data-io-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `SportsDataIoClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/SportsDataIo/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/SportsDataIo/Models/` (+ `src/SportsDataIo/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/SportsDataIo/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/SportsDataIo/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/SportsDataIo/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/SportsDataIo/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/SportsDataIo/Servers/`, `src/SportsDataIo/SportsDataIoClient.cs`, `src/SportsDataIo/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and SportsDataIO may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything @@ -126,17 +129,17 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/SportsDataIo/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step -You loaded every companion the sheet named before starting (per `dotnet-integrate-sportsdata`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: +You loaded every companion the sheet named before starting (per `dotnet-integrate-sportsdataio`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: 1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new SportsDataIoClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) 2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `SportsDataIoClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/sportsdata/skills/dotnet/dotnet-integrate-sportsdata/SKILL.md b/plugins/sportsdata/skills/dotnet/dotnet-integrate-sportsdataio/SKILL.md similarity index 66% rename from plugins/sportsdata/skills/dotnet/dotnet-integrate-sportsdata/SKILL.md rename to plugins/sportsdata/skills/dotnet/dotnet-integrate-sportsdataio/SKILL.md index a260f2006..d965a6710 100644 --- a/plugins/sportsdata/skills/dotnet/dotnet-integrate-sportsdata/SKILL.md +++ b/plugins/sportsdata/skills/dotnet/dotnet-integrate-sportsdataio/SKILL.md @@ -1,125 +1,173 @@ ---- -name: "dotnet-integrate-sportsdata" -description: "MANDATORY FIRST STEP for SportsDataIO .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate SportsDataIO in C# — CbbV3Odds, CbbV3Scores, CbbV3Stats, CfbV3Odds, CfbV3Scores, CfbV3Stats, CwbbV3Scores, GolfV2, GolfV3Headshots, GolfV3Odds, GolfV3RotoBallerPremiumNews, MlbV3Headshots, MlbV3Odds, MlbV3PlayByPlay, MlbV3Projections, MlbV3RotoBallerPremiumNews, MlbV3Scores, MlbV3Stats, MmaV3Odds, MmaV3Scores, MmaV3Stats, NascarV2, NascarV3Odds, NbaV3Headshots, NbaV3Odds, NbaV3PlayByPlay, NbaV3Projections, NbaV3RotoBallerPremiumNews, NbaV3Scores, NbaV3Stats, NflV3Headshots, NflV3Odds, NflV3PlayByPlay, NflV3Projections, NflV3RotoBallerPremiumNews, NflV3Scores, NflV3Stats, NhlV3Headshots, NhlV3Odds, NhlV3PlayByPlay, NhlV3Projections, NhlV3RotoBallerPremiumNews, NhlV3Scores, NhlV3Stats, SoccerV4Headshots, SoccerV4Odds, SoccerV4Projections, SoccerV4Scores, SoccerV4Stats, WnbaV3Scores — or when a SportsDataIO .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file sports-data-io-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." ---- - -# SportsDataIO .NET SDK — Workflow (map + skills) - -You are the SportsDataIO .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. - -**Scope guard:** the APIMatic-generated SportsDataIO **.NET SDK** (root namespace `SportsDataIo`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. - -## Grounding rules - -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `sports-data-io-plan.md` — the plan must stay portable. -- **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. -- **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. -- **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. -- **The sheet records SportsDataIO's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. -- **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. - -## Workflow - -**If the user opens with a reported SDK error or unexpected SportsDataIO behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: - -### Step 1 — Plan first: write `sports-data-io-plan.md` (always, for any implementation work) - -Your FIRST deliverable is `sports-data-io-plan.md` at `/sports-data-io-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. - -#### How to ground (map-first, one pass) - -1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. -3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. -4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. -5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. - -#### Prerequisites you can run alongside - -These need no SDK knowledge and touch no project file, so do them before or alongside the map work: - -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); -- the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; -- locating the SDK reference and its version in the project; -- credentials/environment verification (per the task's secret-handling rules); -- setting up your task tracking. - -Never use this prerequisite work as a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. - -#### HARD GATE - -**No project-file creation or edits until `sports-data-io-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. - -#### The `sports-data-io-plan.md` format (keep it tight — tables, not prose) - -1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. -3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. -6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. -7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. - -| # | Concern | The decision the plan must record | -| --- | --- | --- | -| 1 | **Credential fail-fast** | Where credentials are bound, and that the host refuses to start when one is missing or blank — rather than discovering it as a 401 on the first call in production. A multi-part credential needs **every** part checked, because a blank part is not a missing one. | -| 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | -| 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | -| 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | -| 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | -| 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | -| 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | - -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. - -Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. - -### Step 2 — Required reading (do this before you write any code) - -The contract sheet ends with a **REQUIRED READING** block, and its rows carry inline `MUST load ` pointers. **Load every `dotnet-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. If the sheet names none, load `dotnet-error-handling` anyway — every integration writes an error boundary. - -These are API-agnostic usage skills; loading them is not the same as reading the map. Contract *facts* still come only from the sheet or a map lookup. - -Before implementing, check the plan's **Assumptions & Blockers** section: - -- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `sports-data-io-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. -- Minor assumptions only → proceed. - -Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. - -### Step 3 — Implement from the contract sheet - -1. Read `sports-data-io-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. -2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. -3. After every change: `dotnet build`; fix non-SDK errors yourself. -4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `SportsDataIo.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. - -### Step 4 — Fixing SDK errors (map-first, in place) - -A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `SportsDataIo.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. - -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. -3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". -4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). -5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. -6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. - -If a fix corrects a row in `sports-data-io-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. - -### Step 5 — Pure questions - -A standalone SportsDataIO question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. - -## Anti-patterns — never do these - -- **Never write a SportsDataIO/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** -- **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. -- **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. -- **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search SportsDataIO for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `SportsDataIo` is what the generated source declares, not what public docs describe. -- **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. - +--- +name: "dotnet-integrate-sportsdataio" +description: "MANDATORY FIRST STEP for SportsDataIO .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate SportsDataIO in C# — CbbV3Odds, CbbV3Scores, CbbV3Stats, CfbV3Odds, CfbV3Scores, CfbV3Stats, CwbbV3Scores, GolfV2, GolfV3Headshots, GolfV3Odds, GolfV3RotoBallerPremiumNews, MlbV3Headshots, MlbV3Odds, MlbV3PlayByPlay, MlbV3Projections, MlbV3RotoBallerPremiumNews, MlbV3Scores, MlbV3Stats, MmaV3Odds, MmaV3Scores, MmaV3Stats, NascarV2, NascarV3Odds, NbaV3Headshots, NbaV3Odds, NbaV3PlayByPlay, NbaV3Projections, NbaV3RotoBallerPremiumNews, NbaV3Scores, NbaV3Stats, NflV3Headshots, NflV3Odds, NflV3PlayByPlay, NflV3Projections, NflV3RotoBallerPremiumNews, NflV3Scores, NflV3Stats, NhlV3Headshots, NhlV3Odds, NhlV3PlayByPlay, NhlV3Projections, NhlV3RotoBallerPremiumNews, NhlV3Scores, NhlV3Stats, SoccerV4Headshots, SoccerV4Odds, SoccerV4Projections, SoccerV4Scores, SoccerV4Stats, WnbaV3Scores — or when a SportsDataIO .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file sports-data-io-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." +--- + +# SportsDataIO .NET SDK — Workflow (map + skills) + +You are the SportsDataIO .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. + +**Scope guard:** the APIMatic-generated SportsDataIO **.NET SDK** (root namespace `SportsDataIo`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. + +## Grounding rules + +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `sports-data-io-plan.md` never carries its path — the plan must stay portable. +- **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. +- **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. +- **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. +- **The sheet records SportsDataIO's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. +- **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. + +## Workflow + +**If the user opens with a reported SDK error or unexpected SportsDataIO behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: + +### Step 1 — Plan first: write `sports-data-io-plan.md` (always, for any implementation work) + +Your FIRST deliverable is `sports-data-io-plan.md` at `/sports-data-io-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. + +#### How to ground (map-first, one pass) + +1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. +3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. +4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. +5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. + +#### Prerequisites you can run alongside + +These need no SDK knowledge and touch no project file, so do them before or alongside the map work: + +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; +- `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; +- locating the SDK reference and its version in the project; +- credentials/environment verification (per the task's secret-handling rules); +- setting up your task tracking. + +Never use this prerequisite work as a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. + +#### HARD GATE + +**No project-file creation or edits until `sports-data-io-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. + +#### The `sports-data-io-plan.md` format (keep it tight — tables, not prose) + +1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + +3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. +7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. + +| # | Concern | The decision the plan must record | +| --- | --- | --- | +| 1 | **Credential fail-fast** | Where credentials are bound, and that the host refuses to start when one is missing or blank — rather than discovering it as a 401 on the first call in production. A multi-part credential needs **every** part checked, because a blank part is not a missing one. | +| 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | +| 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | +| 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | +| 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | +| 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. + +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. + +Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. + +### Step 2 — Required reading (do this before you write any code) + +The contract sheet ends with a **REQUIRED READING** block, and its rows carry inline `MUST load ` pointers. **Load every `dotnet-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. If the sheet names none, load `dotnet-error-handling` anyway — every integration writes an error boundary. + +These are API-agnostic usage skills; loading them is not the same as reading the map. Contract *facts* still come only from the sheet or a map lookup. + +Before implementing, check the plan's **Assumptions & Blockers** section: + +- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `sports-data-io-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. +- Minor assumptions only → proceed. + +Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. + +### Step 3 — Implement from the contract sheet + +1. Read `sports-data-io-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. +2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. +3. After every change: `dotnet build`; fix non-SDK errors yourself. +4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `SportsDataIo.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. + +### Step 4 — Fixing SDK errors (map-first, in place) + +A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `SportsDataIo.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. + +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/SportsDataIo/Models/{Type}.cs`, `src/SportsDataIo/Models/Enums/{Type}.cs`, `src/SportsDataIo/Errors/{Type}.cs`, `src/SportsDataIo/Requests/{Type}.cs`, or `src/SportsDataIo/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. +3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". +4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). +5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. +6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. + +If a fix corrects a row in `sports-data-io-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. + +### Step 5 — Pure questions + +A standalone SportsDataIO question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. + +## Anti-patterns — never do these + +- **Never write a SportsDataIO/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** +- **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. +- **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. +- **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. +- **Do not web-search SportsDataIO for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `SportsDataIo` is what the generated source declares, not what public docs describe. +- **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. + diff --git a/plugins/sportsdata/skills/dotnet/dotnet-models/SKILL.md b/plugins/sportsdata/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/sportsdata/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/sportsdata/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/sportsdata/skills/dotnet/dotnet-models/reference.md b/plugins/sportsdata/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/sportsdata/skills/dotnet/dotnet-models/reference.md +++ b/plugins/sportsdata/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/sportsdata/skills/dotnet/dotnet-testing/SKILL.md b/plugins/sportsdata/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/sportsdata/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/sportsdata/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/sportsdata/skills/python/python-authentication/SKILL.md b/plugins/sportsdata/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/sportsdata/skills/python/python-authentication/SKILL.md +++ b/plugins/sportsdata/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/sportsdata/skills/python/python-calling-endpoints/SKILL.md b/plugins/sportsdata/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/sportsdata/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/sportsdata/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/sportsdata/skills/python/python-client-initialization/SKILL.md b/plugins/sportsdata/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/sportsdata/skills/python/python-client-initialization/SKILL.md +++ b/plugins/sportsdata/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/sportsdata/skills/python/python-configuration-resilience/SKILL.md b/plugins/sportsdata/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/sportsdata/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/sportsdata/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/sportsdata/skills/python/python-error-handling/SKILL.md b/plugins/sportsdata/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/sportsdata/skills/python/python-error-handling/SKILL.md +++ b/plugins/sportsdata/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/sportsdata/skills/python/python-event-streams/SKILL.md b/plugins/sportsdata/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/sportsdata/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/sportsdata/skills/python/python-file-handling/SKILL.md b/plugins/sportsdata/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/sportsdata/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/sportsdata/skills/python/python-getting-started/SKILL.md b/plugins/sportsdata/skills/python/python-getting-started/SKILL.md index 8159f7b7b..65ee6e670 100644 --- a/plugins/sportsdata/skills/python/python-getting-started/SKILL.md +++ b/plugins/sportsdata/skills/python/python-getting-started/SKILL.md @@ -5,7 +5,7 @@ description: "SportsDataIO Python SDK identity and lookup layer (Python only) # Getting started with the SportsDataIO Python SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing SportsDataIO Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-sportsdata` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing SportsDataIO Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-sportsdataio` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated Python SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `python-client-initialization`, `python-authentication`, `python-calling-endpoints`, `python-models`, `python-error-handling`, `python-configuration-resilience` and `python-testing`. @@ -25,12 +25,12 @@ Verified against `sports_data_io/` and `pyproject.toml` of the generated package | Version | `1.0` | | Sync client class | `SportsDataIoClient` (alias `Client`) | | Async client class | `AsyncSportsDataIoClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`) · `api_key_header` · `api_key_query`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`) · `retry_options` · `api_key_header` · `api_key_query`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | **API key** in the `Ocp-Apim-Subscription-Key` header — set `api_key_header` · **API key** in the `key` query parameter — set `api_key_query` | | Environments | **no environment enum** — one `base_url` string, defaulting to `https://api.sportsdata.io` | | Base-URL config | `ServerConfig` (`sports_data_io/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | | Surface | 741 operations across 50 controllers · 406 models · 0 unions · 5 enums · 741 per-operation error unions | @@ -39,13 +39,18 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash pip install "sports-data-io @ git+https://github.com/context-plugins/sportsdata-python-sdk.git@main" +python -c "import sports_data_io, pathlib; print(pathlib.Path(sports_data_io.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules @@ -176,15 +181,15 @@ The SDK map's controller table carries the same counts and links to a page per c The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `sports_data_io/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `sports_data_io/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `sports_data_io/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/sportsdata-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. Every `Source` path on the map is relative to that SDK root, so `sports_data_io/models/area.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. @@ -204,7 +209,7 @@ Read the one module that owns the fact **inside the installed package**. Locate python -c "import sports_data_io, pathlib; print(pathlib.Path(sports_data_io.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/sports_data_io` on Windows, `.venv/lib/python3.*/site-packages/sports_data_io` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/sports_data_io` on Windows, `.venv/lib/python3.*/site-packages/sports_data_io` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -212,7 +217,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/sp | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -222,7 +227,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/sp | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | | An operation's error mapper (status → schema) | `errors/_error.py` | @@ -239,13 +244,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The two schemes are `api_key_header=` and `api_key_query=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; **1 operation returns `None`**, so `with_raw_response` is the only way to observe its status code; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`), so an unknown wire value passes through as a plain `str` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -258,8 +263,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 5. **The `ApiError.error` union** for each operation in scope — there is **one** typed error body in this SDK, so the union is never uniform. Every union is ` | RawError`: 1. `Unauthorized` — 741 operations (`cbb_v3_odds.cbb_v3_odds_alternate_market_pre_game_odds_by_date` · `cbb_v3_odds.cbb_v3_odds_alternate_market_pre_game_odds_line_movement` · `cbb_v3_odds.cbb_v3_odds_betting_events_by_date` · `cbb_v3_odds.cbb_v3_odds_betting_events_by_season` · `cbb_v3_odds.cbb_v3_odds_betting_futures_by_season` · `cbb_v3_odds.cbb_v3_odds_betting_futures_by_season_sportsbook_group` · `cbb_v3_odds.cbb_v3_odds_betting_market` · `cbb_v3_odds.cbb_v3_odds_betting_market_sportsbook_group` · `cbb_v3_odds.cbb_v3_odds_betting_markets_by_event` · `cbb_v3_odds.cbb_v3_odds_betting_markets_by_event_sportsbook_group` · `cbb_v3_odds.cbb_v3_odds_betting_markets_by_game` · `cbb_v3_odds.cbb_v3_odds_betting_markets_by_game_sportsbook_group` · `cbb_v3_odds.cbb_v3_odds_betting_markets_by_market_type` · `cbb_v3_odds.cbb_v3_odds_betting_markets_by_market_type_sportsbook_group` · `cbb_v3_odds.cbb_v3_odds_betting_metadata` · `cbb_v3_odds.cbb_v3_odds_betting_player_props_by_game` · `cbb_v3_odds.cbb_v3_odds_betting_player_props_by_game_sportsbook_group` · `cbb_v3_odds.cbb_v3_odds_betting_results_by_market` · `cbb_v3_odds.cbb_v3_odds_betting_results_by_market_sportsbook_group` · `cbb_v3_odds.cbb_v3_odds_betting_splits_by_betting_market` · `cbb_v3_odds.cbb_v3_odds_betting_splits_by_game` · `cbb_v3_odds.cbb_v3_odds_betting_trends_by_matchup` · `cbb_v3_odds.cbb_v3_odds_betting_trends_by_team` · `cbb_v3_odds.cbb_v3_odds_in_game_odds_by_date` · `cbb_v3_odds.cbb_v3_odds_in_game_odds_by_date_sportsbook_group` · `cbb_v3_odds.cbb_v3_odds_in_game_odds_line_movement` · `cbb_v3_odds.cbb_v3_odds_in_game_odds_line_movement_sportsbook_group` · `cbb_v3_odds.cbb_v3_odds_in_game_odds_line_movement_with_resulting_sportsbook_group` · `cbb_v3_odds.cbb_v3_odds_pre_game_and_period_game_odds_by_date_sportsbook_group` · `cbb_v3_odds.cbb_v3_odds_pre_game_and_period_game_odds_line_movement_sportsbook_group` · `cbb_v3_odds.cbb_v3_odds_pre_game_and_period_game_odds_line_movement_with_resulting_sportsbook_group` · `cbb_v3_odds.cbb_v3_odds_pre_game_odds_by_date` · `cbb_v3_odds.cbb_v3_odds_pre_game_odds_line_movement` · `cbb_v3_odds.cbb_v3_odds_sportsbooks_by_active` · `cbb_v3_scores.cbb_v3_scores_are_games_in_progress` · `cbb_v3_scores.cbb_v3_scores_games_by_date_final` · `cbb_v3_scores.cbb_v3_scores_games_by_date_live_final` · `cbb_v3_scores.cbb_v3_scores_games_by_season` · `cbb_v3_scores.cbb_v3_scores_games_by_season_final` · `cbb_v3_scores.cbb_v3_scores_games_basic_by_date` · `cbb_v3_scores.cbb_v3_scores_league_hierarchy` · `cbb_v3_scores.cbb_v3_scores_player_details_by_active` · `cbb_v3_scores.cbb_v3_scores_player_details_by_active2` · `cbb_v3_scores.cbb_v3_scores_player_details_by_injured` · `cbb_v3_scores.cbb_v3_scores_player_details_by_team` · `cbb_v3_scores.cbb_v3_scores_player_profiles_by_team` · `cbb_v3_scores.cbb_v3_scores_schedules_by_team` · `cbb_v3_scores.cbb_v3_scores_schedules_basic` · `cbb_v3_scores.cbb_v3_scores_season_current` · `cbb_v3_scores.cbb_v3_scores_stadiums` · `cbb_v3_scores.cbb_v3_scores_team_game_logs_by_season` · `cbb_v3_scores.cbb_v3_scores_team_game_stats_by_date_live_final` · `cbb_v3_scores.cbb_v3_scores_team_profiles_all` · `cbb_v3_scores.cbb_v3_scores_team_season_stats` · `cbb_v3_scores.cbb_v3_scores_teams_basic_all` · `cbb_v3_scores.cbb_v3_scores_tournament_hierarchy` · `cbb_v3_stats.cbb_v3_stats_box_score_final` · `cbb_v3_stats.cbb_v3_stats_box_score_live_final` · `cbb_v3_stats.cbb_v3_stats_box_scores_by_date_final` · `cbb_v3_stats.cbb_v3_stats_box_scores_by_date_live_final` · `cbb_v3_stats.cbb_v3_stats_box_scores_delta_by_date` · `cbb_v3_stats.cbb_v3_stats_player_game_logs_by_season` · `cbb_v3_stats.cbb_v3_stats_player_game_stats_by_date_final` · `cbb_v3_stats.cbb_v3_stats_player_game_stats_by_date_live_final` · `cbb_v3_stats.cbb_v3_stats_player_season_stats` · `cbb_v3_stats.cbb_v3_stats_player_season_stats_by_team` · `cbb_v3_stats.cbb_v3_stats_team_game_stats_by_date_final` · `cfb_v3_odds.cfb_v3_odds_betting_events_by_date` · `cfb_v3_odds.cfb_v3_odds_betting_events_by_season` · `cfb_v3_odds.cfb_v3_odds_betting_futures_by_season` · `cfb_v3_odds.cfb_v3_odds_betting_futures_by_season_sportsbook_group` · `cfb_v3_odds.cfb_v3_odds_betting_market` · `cfb_v3_odds.cfb_v3_odds_betting_market_sportsbook_group` · `cfb_v3_odds.cfb_v3_odds_betting_markets_by_event` · `cfb_v3_odds.cfb_v3_odds_betting_markets_by_event_sportsbook_group` · `cfb_v3_odds.cfb_v3_odds_betting_markets_by_game` · `cfb_v3_odds.cfb_v3_odds_betting_markets_by_game_sportsbook_group` · `cfb_v3_odds.cfb_v3_odds_betting_markets_by_market_type` · `cfb_v3_odds.cfb_v3_odds_betting_markets_by_market_type_sportsbook_group` · `cfb_v3_odds.cfb_v3_odds_betting_metadata` · `cfb_v3_odds.cfb_v3_odds_betting_player_props_by_game` · `cfb_v3_odds.cfb_v3_odds_betting_player_props_by_game_sportsbook_group` · `cfb_v3_odds.cfb_v3_odds_betting_results_by_market` · `cfb_v3_odds.cfb_v3_odds_betting_results_by_market_sportsbook_group` · `cfb_v3_odds.cfb_v3_odds_betting_splits_by_betting_market` · `cfb_v3_odds.cfb_v3_odds_betting_splits_by_game` · `cfb_v3_odds.cfb_v3_odds_betting_trends_by_matchup` · `cfb_v3_odds.cfb_v3_odds_betting_trends_by_team` · `cfb_v3_odds.cfb_v3_odds_in_game_odds_by_week` · `cfb_v3_odds.cfb_v3_odds_in_game_odds_by_week_sportsbook_group` · `cfb_v3_odds.cfb_v3_odds_in_game_odds_line_movement` · `cfb_v3_odds.cfb_v3_odds_in_game_odds_line_movement_sportsbook_group` · `cfb_v3_odds.cfb_v3_odds_in_game_odds_line_movement_with_resulting_sportsbook_group` · `cfb_v3_odds.cfb_v3_odds_period_game_odds_by_week` · `cfb_v3_odds.cfb_v3_odds_period_game_odds_line_movement` · `cfb_v3_odds.cfb_v3_odds_pre_game_and_period_game_odds_by_week_sportsbook_group` · `cfb_v3_odds.cfb_v3_odds_pre_game_and_period_game_odds_line_movement_sportsbook_group` · `cfb_v3_odds.cfb_v3_odds_pre_game_and_period_game_odds_line_movement_with_resulting_sportsbook_group` · `cfb_v3_odds.cfb_v3_odds_pre_game_odds_by_week` · `cfb_v3_odds.cfb_v3_odds_pre_game_odds_line_movement` · `cfb_v3_odds.cfb_v3_odds_sportsbooks_by_active` · `cfb_v3_scores.cfb_v3_scores_are_games_in_progress` · `cfb_v3_scores.cfb_v3_scores_conference_hierarchy` · `cfb_v3_scores.cfb_v3_scores_games_by_date_live_final` · `cfb_v3_scores.cfb_v3_scores_games_by_week_final` · `cfb_v3_scores.cfb_v3_scores_games_by_week_live_final` · `cfb_v3_scores.cfb_v3_scores_games_basic_by_date_live_final` · `cfb_v3_scores.cfb_v3_scores_games_basic_by_week_final` · `cfb_v3_scores.cfb_v3_scores_games_basic_by_date_final` · `cfb_v3_scores.cfb_v3_scores_games_by_date_final` · `cfb_v3_scores.cfb_v3_scores_player_details_by_active` · `cfb_v3_scores.cfb_v3_scores_player_details_by_active2` · `cfb_v3_scores.cfb_v3_scores_player_details_by_injured` · `cfb_v3_scores.cfb_v3_scores_player_details_by_team` · `cfb_v3_scores.cfb_v3_scores_player_profiles_by_team` · `cfb_v3_scores.cfb_v3_scores_schedules` · `cfb_v3_scores.cfb_v3_scores_schedules_basic` · `cfb_v3_scores.cfb_v3_scores_season_current` · `cfb_v3_scores.cfb_v3_scores_season_current_details` · `cfb_v3_scores.cfb_v3_scores_season_type_current` · `cfb_v3_scores.cfb_v3_scores_stadiums` · `cfb_v3_scores.cfb_v3_scores_team_game_logs_by_season` · `cfb_v3_scores.cfb_v3_scores_team_game_stats_by_week_live_final` · `cfb_v3_scores.cfb_v3_scores_team_profiles_all` · `cfb_v3_scores.cfb_v3_scores_team_season_stats_standings` · `cfb_v3_scores.cfb_v3_scores_teams_basic_all` · `cfb_v3_scores.cfb_v3_scores_week_current` · `cfb_v3_stats.cfb_v3_stats_box_score_final` · `cfb_v3_stats.cfb_v3_stats_box_score_live_final` · `cfb_v3_stats.cfb_v3_stats_box_scores_by_date_final` · `cfb_v3_stats.cfb_v3_stats_box_scores_by_date_live_final` · `cfb_v3_stats.cfb_v3_stats_box_scores_by_week_final` · `cfb_v3_stats.cfb_v3_stats_box_scores_by_week_live_final` · `cfb_v3_stats.cfb_v3_stats_box_scores_delta_by_week` · `cfb_v3_stats.cfb_v3_stats_player_game_logs_by_season` · `cfb_v3_stats.cfb_v3_stats_player_game_stats_by_week_final` · `cfb_v3_stats.cfb_v3_stats_player_game_stats_by_week_live_final` · `cfb_v3_stats.cfb_v3_stats_player_season_stats` · `cfb_v3_stats.cfb_v3_stats_player_season_stats_by_team` · `cfb_v3_stats.cfb_v3_stats_team_game_stats_by_week_final` · `cwbb_v3_scores.cwbb_v3_scores_are_any_games_in_progress` · `cwbb_v3_scores.cwbb_v3_scores_games_by_date_final` · `cwbb_v3_scores.cwbb_v3_scores_games_by_date_live_final` · `cwbb_v3_scores.cwbb_v3_scores_league_hierarchy` · `cwbb_v3_scores.cwbb_v3_scores_schedules` · `cwbb_v3_scores.cwbb_v3_scores_schedules_basic` · `cwbb_v3_scores.cwbb_v3_scores_season_current` · `cwbb_v3_scores.cwbb_v3_scores_team_profiles_all` · `golf_v2.golf_v2_courses_courses` · `golf_v2.golf_v2_currentseason_season_current` · `golf_v2.golf_v2_dfsslateownershipprojectionsbyslateid_dfs_slate_ownership_projections_by_slate` · `golf_v2.golf_v2_dfsslatesbytournament_dfs_slates_by_tournament` · `golf_v2.golf_v2_fantasygamestatsbytournament_fantasy_points_by_tournament` · `golf_v2.golf_v2_leaderboard_leaderboard_live_final` · `golf_v2.golf_v2_leaderboardbasic_leaderboard_basic` · `golf_v2.golf_v2_leaderboardbasicfinal_leaderboard_basic_final` · `golf_v2.golf_v2_leaderboardfinal_leaderboard_final` · `golf_v2.golf_v2_news_news` · `golf_v2.golf_v2_newsbydate_news_by_date` · `golf_v2.golf_v2_player_player` · `golf_v2.golf_v2_players_player_profiles_all` · `golf_v2.golf_v2_playerseasonstats_player_season_stats` · `golf_v2.golf_v2_playertournamentholescores_player_hole_scores_by_tournament_live_final` · `golf_v2.golf_v2_playertournamentholescoresfinal_player_hole_scores_by_tournament_final` · `golf_v2.golf_v2_playertournamentprojectionstats_player_projected_stats_by_tournament` · `golf_v2.golf_v2_playertournamentroundscores_scores_by_tournament_live_final` · `golf_v2.golf_v2_playertournamentroundscoresfinal_scores_by_tournament_final` · `golf_v2.golf_v2_rankings_rankings` · `golf_v2.golf_v2_tournaments_schedule_by_season` · `golf_v2.golf_v2_tournaments_schedules` · `golf_v2.golf_v2_upcomingdfsslateownershipprojections_dfs_slate_ownership_projections_upcoming` · `golf_v3_headshots.golf_v3_headshots_headshots` · `golf_v3_odds.golf_v3_odds_betting_events_by_date` · `golf_v3_odds.golf_v3_odds_betting_events_by_season` · `golf_v3_odds.golf_v3_odds_betting_market` · `golf_v3_odds.golf_v3_odds_betting_market_sportsbook_group` · `golf_v3_odds.golf_v3_odds_betting_markets_by_event` · `golf_v3_odds.golf_v3_odds_betting_markets_by_event_sportsbook_group` · `golf_v3_odds.golf_v3_odds_betting_markets_by_market_type` · `golf_v3_odds.golf_v3_odds_betting_markets_by_market_type_sportsbook_group` · `golf_v3_odds.golf_v3_odds_betting_markets_by_tournament` · `golf_v3_odds.golf_v3_odds_betting_markets_by_tournament_sportsbook_group` · `golf_v3_odds.golf_v3_odds_betting_metadata` · `golf_v3_odds.golf_v3_odds_betting_player_props_by_tournament_sportsbook_group` · `golf_v3_odds.golf_v3_odds_in_play_tournament_odds` · `golf_v3_odds.golf_v3_odds_in_play_tournament_odds_sportsbook_group` · `golf_v3_odds.golf_v3_odds_in_play_tournament_odds_line_movement` · `golf_v3_odds.golf_v3_odds_in_play_tournament_odds_line_movement_sportsbook_group` · `golf_v3_odds.golf_v3_odds_sportsbooks_by_active` · `golf_v3_odds.golf_v3_odds_tournament_odds` · `golf_v3_odds.golf_v3_odds_tournament_odds_sportsbook_group` · `golf_v3_odds.golf_v3_odds_tournament_odds_line_movement` · `golf_v3_odds.golf_v3_odds_tournament_odds_line_movement_sportsbook_group` · `golf_v3_roto_baller_premium_news.golf_v3_news_rotoballer_premium_news` · `golf_v3_roto_baller_premium_news.golf_v3_news_rotoballer_premium_news_by_date` · `mlb_v3_headshots.mlb_v3_headshots_headshots` · `mlb_v3_odds.mlb_v3_odds_betting_events_by_date` · `mlb_v3_odds.mlb_v3_odds_betting_events_by_season` · `mlb_v3_odds.mlb_v3_odds_betting_futures_by_season` · `mlb_v3_odds.mlb_v3_odds_betting_futures_by_season_sportsbook_group` · `mlb_v3_odds.mlb_v3_odds_betting_market` · `mlb_v3_odds.mlb_v3_odds_betting_market_sportsbook_group` · `mlb_v3_odds.mlb_v3_odds_betting_markets_by_event` · `mlb_v3_odds.mlb_v3_odds_betting_markets_by_event_sportsbook_group` · `mlb_v3_odds.mlb_v3_odds_betting_markets_by_game` · `mlb_v3_odds.mlb_v3_odds_betting_markets_by_game_sportsbook_group` · `mlb_v3_odds.mlb_v3_odds_betting_markets_by_market_type` · `mlb_v3_odds.mlb_v3_odds_betting_markets_by_market_type_sportsbook_group` · `mlb_v3_odds.mlb_v3_odds_betting_metadata` · `mlb_v3_odds.mlb_v3_odds_betting_player_props_by_game` · `mlb_v3_odds.mlb_v3_odds_betting_player_props_by_game_sportsbook_group` · `mlb_v3_odds.mlb_v3_odds_betting_results_by_market` · `mlb_v3_odds.mlb_v3_odds_betting_results_by_market_sportsbook_group` · `mlb_v3_odds.mlb_v3_odds_betting_splits_by_game` · `mlb_v3_odds.mlb_v3_odds_betting_splits_by_betting_market` · `mlb_v3_odds.mlb_v3_odds_betting_trends_by_matchup` · `mlb_v3_odds.mlb_v3_odds_betting_trends_by_team` · `mlb_v3_odds.mlb_v3_odds_in_game_odds_by_date` · `mlb_v3_odds.mlb_v3_odds_in_game_odds_by_date_sportsbook_group` · `mlb_v3_odds.mlb_v3_odds_in_game_odds_line_movement` · `mlb_v3_odds.mlb_v3_odds_in_game_odds_line_movement_sportsbook_group` · `mlb_v3_odds.mlb_v3_odds_in_game_odds_line_movement_with_resulting_sportsbook_group` · `mlb_v3_odds.mlb_v3_odds_period_game_odds_by_date` · `mlb_v3_odds.mlb_v3_odds_period_game_odds_line_movement` · `mlb_v3_odds.mlb_v3_odds_pre_game_and_period_game_odds_by_date_sportsbook_group` · `mlb_v3_odds.mlb_v3_odds_pre_game_and_period_game_odds_line_movement_sportsbook_group` · `mlb_v3_odds.mlb_v3_odds_pre_game_and_period_game_odds_line_movement_with_resulting_sportsbook_group` · `mlb_v3_odds.mlb_v3_odds_pre_game_odds_by_date` · `mlb_v3_odds.mlb_v3_odds_pre_game_odds_line_movement` · `mlb_v3_odds.mlb_v3_odds_sportsbooks_by_active` · `mlb_v3_play_by_play.mlb_v3_pbp_play_by_play_final` · `mlb_v3_play_by_play.mlb_v3_pbp_play_by_play_live_final` · `mlb_v3_play_by_play.mlb_v3_pbp_play_by_play_delta` · `mlb_v3_projections.mlb_v3_projections_depth_charts` · `mlb_v3_projections.mlb_v3_projections_dfs_slate_ownership_projections_by_slate` · `mlb_v3_projections.mlb_v3_projections_dfs_slate_ownership_projections_upcoming` · `mlb_v3_projections.mlb_v3_projections_dfs_slates_by_date` · `mlb_v3_projections.mlb_v3_projections_player_details_by_injured` · `mlb_v3_projections.mlb_v3_projections_projected_player_game_stats_by_date` · `mlb_v3_projections.mlb_v3_projections_projected_player_season_stats_with_adp` · `mlb_v3_projections.mlb_v3_projections_starting_lineups_by_date` · `mlb_v3_roto_baller_premium_news.mlb_v3_news_rotoballer_premium_news` · `mlb_v3_roto_baller_premium_news.mlb_v3_news_rotoballer_premium_news_by_date` · `mlb_v3_scores.mlb_v3_scores_are_games_in_progress` · `mlb_v3_scores.mlb_v3_scores_games_by_date_final` · `mlb_v3_scores.mlb_v3_scores_games_by_date_live_final` · `mlb_v3_scores.mlb_v3_scores_games_basic_by_date_final` · `mlb_v3_scores.mlb_v3_scores_games_basic_by_date_live_final` · `mlb_v3_scores.mlb_v3_scores_news` · `mlb_v3_scores.mlb_v3_scores_news_by_date` · `mlb_v3_scores.mlb_v3_scores_player_details_by_active` · `mlb_v3_scores.mlb_v3_scores_player_details_by_free_agents` · `mlb_v3_scores.mlb_v3_scores_player_profiles_by_active` · `mlb_v3_scores.mlb_v3_scores_player_profiles_by_free_agents` · `mlb_v3_scores.mlb_v3_scores_player_profiles_by_team` · `mlb_v3_scores.mlb_v3_scores_players_details_by_team` · `mlb_v3_scores.mlb_v3_scores_schedules` · `mlb_v3_scores.mlb_v3_scores_schedules_basic` · `mlb_v3_scores.mlb_v3_scores_season_current` · `mlb_v3_scores.mlb_v3_scores_stadiums` · `mlb_v3_scores.mlb_v3_scores_standings` · `mlb_v3_scores.mlb_v3_scores_team_game_logs_by_season` · `mlb_v3_scores.mlb_v3_scores_team_game_stats_by_date_final` · `mlb_v3_scores.mlb_v3_scores_team_game_stats_by_date_live_final` · `mlb_v3_scores.mlb_v3_scores_team_profiles_all` · `mlb_v3_scores.mlb_v3_scores_team_profiles_by_active` · `mlb_v3_scores.mlb_v3_scores_team_profiles_by_season` · `mlb_v3_scores.mlb_v3_scores_team_season_stats` · `mlb_v3_scores.mlb_v3_scores_transactions` · `mlb_v3_stats.mlb_v3_stats_batter_vs_pitcher_stats` · `mlb_v3_stats.mlb_v3_stats_box_score_final` · `mlb_v3_stats.mlb_v3_stats_box_score_live_final` · `mlb_v3_stats.mlb_v3_stats_box_scores_by_date_final` · `mlb_v3_stats.mlb_v3_stats_box_scores_by_date_live_final` · `mlb_v3_stats.mlb_v3_stats_box_scores_delta_by_date` · `mlb_v3_stats.mlb_v3_stats_fantasy_points_by_date` · `mlb_v3_stats.mlb_v3_stats_player_game_logs_by_season` · `mlb_v3_stats.mlb_v3_stats_player_game_stats_by_date_final` · `mlb_v3_stats.mlb_v3_stats_player_game_stats_by_date_live_final` · `mlb_v3_stats.mlb_v3_stats_player_season_split_stats` · `mlb_v3_stats.mlb_v3_stats_player_season_stats` · `mlb_v3_stats.mlb_v3_stats_player_season_stats_by_away` · `mlb_v3_stats.mlb_v3_stats_player_season_stats_by_home` · `mlb_v3_stats.mlb_v3_stats_player_season_stats_by_team` · `mlb_v3_stats.mlb_v3_stats_player_season_stats_split_by_team` · `mlb_v3_stats.mlb_v3_stats_team_hitting_vs_starting_pitcher` · `mma_v3_odds.mma_v3_odds_betting_events_by_event` · `mma_v3_odds.mma_v3_odds_betting_events_upcoming` · `mma_v3_odds.mma_v3_odds_betting_fighter_props_by_event` · `mma_v3_odds.mma_v3_odds_betting_market` · `mma_v3_odds.mma_v3_odds_betting_markets_by_event` · `mma_v3_odds.mma_v3_odds_betting_markets_by_market_type` · `mma_v3_odds.mma_v3_odds_betting_metadata` · `mma_v3_odds.mma_v3_odds_event_fight_odds` · `mma_v3_odds.mma_v3_odds_event_fight_odds_line_movement` · `mma_v3_odds.mma_v3_odds_sportsbooks_by_active` · `mma_v3_scores.mma_v3_scores_event` · `mma_v3_scores.mma_v3_scores_fighter_profile_by_fighter` · `mma_v3_scores.mma_v3_scores_fighter_profiles_all` · `mma_v3_scores.mma_v3_scores_fighter_profiles_basic_all` · `mma_v3_scores.mma_v3_scores_leagues` · `mma_v3_scores.mma_v3_scores_schedules` · `mma_v3_stats.mma_v3_stats_fight_basic_final` · `mma_v3_stats.mma_v3_stats_fight_basic_live_final` · `mma_v3_stats.mma_v3_stats_fight_final` · `mma_v3_stats.mma_v3_stats_fight_live_final` · `nascar_v2.nascar_v2_driver_driver_profiles_by_driver` · `nascar_v2.nascar_v2_driverraceprojections_projected_player_race_stats_by_race` · `nascar_v2.nascar_v2_drivers_driver_profiles_all` · `nascar_v2.nascar_v2_driversbyactive_drivers_by_active` · `nascar_v2.nascar_v2_entrylistbyrace_entry_list_by_race` · `nascar_v2.nascar_v2_fantasygamestatsbyrace_fantasy_points_by_race` · `nascar_v2.nascar_v2_leaderboardbasic_leaderboard_basic` · `nascar_v2.nascar_v2_leaderboardbasicfinal_leaderboard_basic_final` · `nascar_v2.nascar_v2_raceresult_leaderboard_live_final` · `nascar_v2.nascar_v2_raceresultfinal_leaderboard_final` · `nascar_v2.nascar_v2_races_schedules` · `nascar_v2.nascar_v2_series_series` · `nascar_v2.nascar_v2_tracks_tracks` · `nascar_v3_odds.nascar_v3_odds_betting_events_by_date` · `nascar_v3_odds.nascar_v3_odds_betting_events_by_season` · `nascar_v3_odds.nascar_v3_odds_betting_market` · `nascar_v3_odds.nascar_v3_odds_betting_markets_by_event` · `nascar_v3_odds.nascar_v3_odds_betting_markets_by_market_type` · `nascar_v3_odds.nascar_v3_odds_betting_markets_by_race` · `nascar_v3_odds.nascar_v3_odds_betting_metadata` · `nascar_v3_odds.nascar_v3_odds_race_odds` · `nascar_v3_odds.nascar_v3_odds_race_odds_line_movement` · `nascar_v3_odds.nascar_v3_odds_sportsbooks_by_active` · `nba_v3_headshots.nba_v3_headshots_headshots` · `nba_v3_odds.nba_v3_odds_betting_events_by_date` · `nba_v3_odds.nba_v3_odds_betting_events_by_season` · `nba_v3_odds.nba_v3_odds_betting_futures_by_season` · `nba_v3_odds.nba_v3_odds_betting_futures_by_season_sportsbook_group` · `nba_v3_odds.nba_v3_odds_betting_market` · `nba_v3_odds.nba_v3_odds_betting_market_sportsbook_group` · `nba_v3_odds.nba_v3_odds_betting_markets_by_event` · `nba_v3_odds.nba_v3_odds_betting_markets_by_event_sportsbook_group` · `nba_v3_odds.nba_v3_odds_betting_markets_by_game` · `nba_v3_odds.nba_v3_odds_betting_markets_by_game_sportsbook_group` · `nba_v3_odds.nba_v3_odds_betting_markets_by_market_type` · `nba_v3_odds.nba_v3_odds_betting_markets_by_market_type_sportsbook_group` · `nba_v3_odds.nba_v3_odds_betting_metadata` · `nba_v3_odds.nba_v3_odds_betting_player_props_by_game` · `nba_v3_odds.nba_v3_odds_betting_player_props_by_game_sportsbook_group` · `nba_v3_odds.nba_v3_odds_betting_results_by_market` · `nba_v3_odds.nba_v3_odds_betting_results_by_market_sportsbook_group` · `nba_v3_odds.nba_v3_odds_betting_splits_by_betting_market` · `nba_v3_odds.nba_v3_odds_betting_splits_by_game` · `nba_v3_odds.nba_v3_odds_betting_trends_by_matchup` · `nba_v3_odds.nba_v3_odds_betting_trends_by_team` · `nba_v3_odds.nba_v3_odds_in_game_odds_by_date` · `nba_v3_odds.nba_v3_odds_in_game_odds_by_date_sportsbook_group` · `nba_v3_odds.nba_v3_odds_in_game_odds_line_movement` · `nba_v3_odds.nba_v3_odds_in_game_odds_line_movement_sportsbook_group` · `nba_v3_odds.nba_v3_odds_in_game_odds_line_movement_with_resulting_sportsbook_group` · `nba_v3_odds.nba_v3_odds_period_game_odds_by_date` · `nba_v3_odds.nba_v3_odds_period_game_odds_line_movement` · `nba_v3_odds.nba_v3_odds_pre_game_and_period_game_odds_by_date_sportsbook_group` · `nba_v3_odds.nba_v3_odds_pre_game_and_period_game_odds_line_movement_sportsbook_group` · `nba_v3_odds.nba_v3_odds_pre_game_and_period_game_odds_line_movement_with_resulting_sportsbook_group` · `nba_v3_odds.nba_v3_odds_pre_game_odds_by_date` · `nba_v3_odds.nba_v3_odds_pre_game_odds_line_movement` · `nba_v3_odds.nba_v3_odds_sportsbooks_by_active` · `nba_v3_play_by_play.nba_v3_pbp_play_by_play_final` · `nba_v3_play_by_play.nba_v3_pbp_play_by_play_live_final` · `nba_v3_play_by_play.nba_v3_pbp_play_by_play_delta` · `nba_v3_projections.nba_v3_projections_dfs_slates_by_date` · `nba_v3_projections.nba_v3_projections_player_details_by_injured` · `nba_v3_projections.nba_v3_projections_projected_player_game_stats_by_date` · `nba_v3_projections.nba_v3_projections_projected_player_season_stats` · `nba_v3_projections.nba_v3_projections_projected_player_season_stats_by_team` · `nba_v3_projections.nba_v3_projections_starting_lineups_by_date` · `nba_v3_roto_baller_premium_news.nba_v3_news_rotoballer_premium_news` · `nba_v3_roto_baller_premium_news.nba_v3_news_rotoballer_premium_news_by_date` · `nba_v3_scores.nba_v3_scores_are_games_in_progress` · `nba_v3_scores.nba_v3_scores_depth_charts` · `nba_v3_scores.nba_v3_scores_games_by_date_final` · `nba_v3_scores.nba_v3_scores_games_by_date_live_final` · `nba_v3_scores.nba_v3_scores_games_basic_by_date_final` · `nba_v3_scores.nba_v3_scores_games_basic_by_date_live_final` · `nba_v3_scores.nba_v3_scores_news` · `nba_v3_scores.nba_v3_scores_news_by_date` · `nba_v3_scores.nba_v3_scores_player_details_by_active` · `nba_v3_scores.nba_v3_scores_player_details_by_free_agent` · `nba_v3_scores.nba_v3_scores_player_details_by_team` · `nba_v3_scores.nba_v3_scores_player_profiles_by_active` · `nba_v3_scores.nba_v3_scores_player_profiles_by_free_agent` · `nba_v3_scores.nba_v3_scores_players_profiles_by_team` · `nba_v3_scores.nba_v3_scores_referees` · `nba_v3_scores.nba_v3_scores_schedules` · `nba_v3_scores.nba_v3_scores_schedules_basic` · `nba_v3_scores.nba_v3_scores_season_current` · `nba_v3_scores.nba_v3_scores_stadiums` · `nba_v3_scores.nba_v3_scores_standings` · `nba_v3_scores.nba_v3_scores_team_game_logs_by_season` · `nba_v3_scores.nba_v3_scores_team_game_stats_by_date_live_final` · `nba_v3_scores.nba_v3_scores_team_profiles_all` · `nba_v3_scores.nba_v3_scores_team_profiles_by_active` · `nba_v3_scores.nba_v3_scores_team_profiles_by_season` · `nba_v3_scores.nba_v3_scores_team_season_stats` · `nba_v3_scores.nba_v3_scores_transactions` · `nba_v3_stats.nba_v3_stats_all_stars` · `nba_v3_stats.nba_v3_stats_box_score_final` · `nba_v3_stats.nba_v3_stats_box_score_live_final` · `nba_v3_stats.nba_v3_stats_box_scores_by_date_final` · `nba_v3_stats.nba_v3_stats_box_scores_by_date_live_final` · `nba_v3_stats.nba_v3_stats_box_scores_delta_by_date` · `nba_v3_stats.nba_v3_stats_fantasy_points_by_date` · `nba_v3_stats.nba_v3_stats_player_game_logs_by_season` · `nba_v3_stats.nba_v3_stats_player_game_stats_by_date_final` · `nba_v3_stats.nba_v3_stats_player_game_stats_by_date_live_final` · `nba_v3_stats.nba_v3_stats_player_season_stats` · `nba_v3_stats.nba_v3_stats_player_season_stats_by_team` · `nba_v3_stats.nba_v3_stats_team_game_stats_by_date_final` · `nba_v3_stats.nba_v3_stats_team_stats_allowed_by_position` · `nfl_v3_headshots.nfl_v3_headshots_headshots` · `nfl_v3_odds.nfl_v3_odds_betting_events_by_date` · `nfl_v3_odds.nfl_v3_odds_betting_events_by_season` · `nfl_v3_odds.nfl_v3_odds_betting_futures_by_season` · `nfl_v3_odds.nfl_v3_odds_betting_futures_by_season_sportsbook_group` · `nfl_v3_odds.nfl_v3_odds_betting_market` · `nfl_v3_odds.nfl_v3_odds_betting_market_sportsbook_group` · `nfl_v3_odds.nfl_v3_odds_betting_markets_by_event` · `nfl_v3_odds.nfl_v3_odds_betting_markets_by_event_sportsbook_group` · `nfl_v3_odds.nfl_v3_odds_betting_markets_by_game` · `nfl_v3_odds.nfl_v3_odds_betting_markets_by_game_sportsbook_group` · `nfl_v3_odds.nfl_v3_odds_betting_markets_by_market_type` · `nfl_v3_odds.nfl_v3_odds_betting_markets_by_market_type_sportsbook_group` · `nfl_v3_odds.nfl_v3_odds_betting_metadata` · `nfl_v3_odds.nfl_v3_odds_betting_player_props_by_game` · `nfl_v3_odds.nfl_v3_odds_betting_player_props_by_game_sportsbook_group` · `nfl_v3_odds.nfl_v3_odds_betting_results_by_market` · `nfl_v3_odds.nfl_v3_odds_betting_results_by_market_sportsbook_group` · `nfl_v3_odds.nfl_v3_odds_betting_splits_by_betting_market` · `nfl_v3_odds.nfl_v3_odds_betting_splits_by_game` · `nfl_v3_odds.nfl_v3_odds_betting_trends_by_matchup` · `nfl_v3_odds.nfl_v3_odds_betting_trends_by_team` · `nfl_v3_odds.nfl_v3_odds_in_game_odds_by_week` · `nfl_v3_odds.nfl_v3_odds_in_game_odds_by_week_sportsbook_group` · `nfl_v3_odds.nfl_v3_odds_in_game_odds_line_movement` · `nfl_v3_odds.nfl_v3_odds_in_game_odds_line_movement_sportsbook_group` · `nfl_v3_odds.nfl_v3_odds_in_game_odds_line_movement_with_resulting_sportsbook_group` · `nfl_v3_odds.nfl_v3_odds_period_game_odds_by_week` · `nfl_v3_odds.nfl_v3_odds_period_game_odds_line_movement` · `nfl_v3_odds.nfl_v3_odds_pre_game_and_period_game_odds_by_week_sportsbook_group` · `nfl_v3_odds.nfl_v3_odds_pre_game_and_period_game_odds_line_movement_sportsbook_group` · `nfl_v3_odds.nfl_v3_odds_pre_game_and_period_game_odds_line_movement_with_resulting_sportsbook_group` · `nfl_v3_odds.nfl_v3_odds_pre_game_odds_by_week` · `nfl_v3_odds.nfl_v3_odds_pre_game_odds_line_movement` · `nfl_v3_odds.nfl_v3_odds_sportsbooks_active` · `nfl_v3_play_by_play.nfl_v3_pbp_play_by_play_by_team_final` · `nfl_v3_play_by_play.nfl_v3_pbp_play_by_play_by_team_live_final` · `nfl_v3_play_by_play.nfl_v3_pbp_play_by_play_final` · `nfl_v3_play_by_play.nfl_v3_pbp_play_by_play_live_final` · `nfl_v3_play_by_play.nfl_v3_pbp_play_by_play_delta` · `nfl_v3_play_by_play.nfl_v3_pbp_play_by_play_simulation` · `nfl_v3_projections.nfl_v3_projections_dfs_slate_ownership_projections_by_slate` · `nfl_v3_projections.nfl_v3_projections_dfs_slate_ownership_projections_upcoming` · `nfl_v3_projections.nfl_v3_projections_dfs_slates_by_date` · `nfl_v3_projections.nfl_v3_projections_dfs_slates_by_week` · `nfl_v3_projections.nfl_v3_projections_idp_projected_player_game_stats_by_team` · `nfl_v3_projections.nfl_v3_projections_idp_projected_player_game_stats_by_week` · `nfl_v3_projections.nfl_v3_projections_player_details_by_injured` · `nfl_v3_projections.nfl_v3_projections_projected_fantasy_defense_game_stats_with_dfs_salaries` · `nfl_v3_projections.nfl_v3_projections_projected_fantasy_defense_season_stats_with_adp` · `nfl_v3_projections.nfl_v3_projections_projected_player_game_stats_by_team` · `nfl_v3_projections.nfl_v3_projections_projected_player_game_stats_by_week` · `nfl_v3_projections.nfl_v3_projections_projected_player_season_stats_with_adp` · `nfl_v3_projections.nfl_v3_projections_projected_player_season_stats_with_adp_by_team` · `nfl_v3_roto_baller_premium_news.nfl_v3_news_rotoballer_premium_news` · `nfl_v3_roto_baller_premium_news.nfl_v3_news_rotoballer_premium_news_by_date` · `nfl_v3_roto_baller_premium_news.nfl_v3_news_rotoballer_premium_news_by_team` · `nfl_v3_scores.nfl_v3_scores_are_games_in_progress` · `nfl_v3_scores.nfl_v3_scores_bye_weeks` · `nfl_v3_scores.nfl_v3_scores_depth_charts_all` · `nfl_v3_scores.nfl_v3_scores_depth_charts_by_active` · `nfl_v3_scores.nfl_v3_scores_depth_charts_by_week` · `nfl_v3_scores.nfl_v3_scores_games_by_date_final` · `nfl_v3_scores.nfl_v3_scores_games_by_date_live_final` · `nfl_v3_scores.nfl_v3_scores_games_by_season_live_final` · `nfl_v3_scores.nfl_v3_scores_games_by_week_final` · `nfl_v3_scores.nfl_v3_scores_games_by_week_live_final` · `nfl_v3_scores.nfl_v3_scores_games_basic_by_week_final` · `nfl_v3_scores.nfl_v3_scores_games_basic_by_week_live_final` · `nfl_v3_scores.nfl_v3_scores_news` · `nfl_v3_scores.nfl_v3_scores_news_by_date` · `nfl_v3_scores.nfl_v3_scores_news_by_team` · `nfl_v3_scores.nfl_v3_scores_player_details_all` · `nfl_v3_scores.nfl_v3_scores_player_details_by_free_agents` · `nfl_v3_scores.nfl_v3_scores_player_details_by_rookie_draft_year` · `nfl_v3_scores.nfl_v3_scores_player_details_by_team` · `nfl_v3_scores.nfl_v3_scores_player_game_logs_by_season` · `nfl_v3_scores.nfl_v3_scores_player_profiles_all` · `nfl_v3_scores.nfl_v3_scores_player_profiles_by_free_agent` · `nfl_v3_scores.nfl_v3_scores_player_profiles_by_rookie_draft_year` · `nfl_v3_scores.nfl_v3_scores_player_profiles_by_team` · `nfl_v3_scores.nfl_v3_scores_referees` · `nfl_v3_scores.nfl_v3_scores_schedules` · `nfl_v3_scores.nfl_v3_scores_schedules_basic` · `nfl_v3_scores.nfl_v3_scores_scores_by_week_simulation` · `nfl_v3_scores.nfl_v3_scores_season_current` · `nfl_v3_scores.nfl_v3_scores_season_last_completed` · `nfl_v3_scores.nfl_v3_scores_season_upcoming` · `nfl_v3_scores.nfl_v3_scores_stadiums` · `nfl_v3_scores.nfl_v3_scores_standings` · `nfl_v3_scores.nfl_v3_scores_team_game_stats_live_final` · `nfl_v3_scores.nfl_v3_scores_team_profiles_all` · `nfl_v3_scores.nfl_v3_scores_team_profiles_by_active` · `nfl_v3_scores.nfl_v3_scores_team_profiles_by_season` · `nfl_v3_scores.nfl_v3_scores_team_profiles_basic_all` · `nfl_v3_scores.nfl_v3_scores_team_season_stats` · `nfl_v3_scores.nfl_v3_scores_timeframes` · `nfl_v3_scores.nfl_v3_scores_transactions_by_date` · `nfl_v3_scores.nfl_v3_scores_week_current` · `nfl_v3_scores.nfl_v3_scores_week_last_completed` · `nfl_v3_scores.nfl_v3_scores_week_upcoming` · `nfl_v3_stats.nfl_v3_stats_box_score_by_team_final` · `nfl_v3_stats.nfl_v3_stats_box_score_by_team_live_final` · `nfl_v3_stats.nfl_v3_stats_box_score_final` · `nfl_v3_stats.nfl_v3_stats_box_score_live_final` · `nfl_v3_stats.nfl_v3_stats_box_scores_by_week_final` · `nfl_v3_stats.nfl_v3_stats_box_scores_delta_by_week` · `nfl_v3_stats.nfl_v3_stats_box_scores_simulation` · `nfl_v3_stats.nfl_v3_stats_fantasy_defense_game_stats_all` · `nfl_v3_stats.nfl_v3_stats_fantasy_defense_game_stats_by_team` · `nfl_v3_stats.nfl_v3_stats_fantasy_defense_season_stats_all` · `nfl_v3_stats.nfl_v3_stats_fantasy_defense_season_stats_by_team` · `nfl_v3_stats.nfl_v3_stats_fantasy_player_ownership_percentages_season_long_by_week` · `nfl_v3_stats.nfl_v3_stats_fantasy_points_by_week` · `nfl_v3_stats.nfl_v3_stats_games_by_season_final` · `nfl_v3_stats.nfl_v3_stats_injuries_all` · `nfl_v3_stats.nfl_v3_stats_injuries_by_team` · `nfl_v3_stats.nfl_v3_stats_player_game_logs_by_season` · `nfl_v3_stats.nfl_v3_stats_player_game_red_zone_stats` · `nfl_v3_stats.nfl_v3_stats_player_game_red_zone_stats_inside_five` · `nfl_v3_stats.nfl_v3_stats_player_game_red_zone_stats_inside_ten` · `nfl_v3_stats.nfl_v3_stats_player_game_stats_by_team_final` · `nfl_v3_stats.nfl_v3_stats_player_game_stats_by_team_live_final` · `nfl_v3_stats.nfl_v3_stats_player_game_stats_by_week_final` · `nfl_v3_stats.nfl_v3_stats_player_game_stats_by_week_live_final` · `nfl_v3_stats.nfl_v3_stats_player_game_stats_delta` · `nfl_v3_stats.nfl_v3_stats_player_game_stats_delta_by_week` · `nfl_v3_stats.nfl_v3_stats_player_season_red_zone_stats` · `nfl_v3_stats.nfl_v3_stats_player_season_red_zone_stats_inside_five` · `nfl_v3_stats.nfl_v3_stats_player_season_red_zone_stats_inside_ten` · `nfl_v3_stats.nfl_v3_stats_player_season_stats` · `nfl_v3_stats.nfl_v3_stats_player_season_stats_by_team` · `nfl_v3_stats.nfl_v3_stats_player_season_third_down_stats` · `nfl_v3_stats.nfl_v3_stats_pro_bowlers` · `nfl_v3_stats.nfl_v3_stats_team_game_stats_by_game_final` · `nhl_v3_headshots.nhl_v3_headshots_headshots` · `nhl_v3_odds.nhl_v3_odds_betting_events_by_date` · `nhl_v3_odds.nhl_v3_odds_betting_events_by_season` · `nhl_v3_odds.nhl_v3_odds_betting_futures_by_season` · `nhl_v3_odds.nhl_v3_odds_betting_futures_by_season_sportsbook_group` · `nhl_v3_odds.nhl_v3_odds_betting_market` · `nhl_v3_odds.nhl_v3_odds_betting_market_sportsbook_group` · `nhl_v3_odds.nhl_v3_odds_betting_markets_by_event` · `nhl_v3_odds.nhl_v3_odds_betting_markets_by_event_sportsbook_group` · `nhl_v3_odds.nhl_v3_odds_betting_markets_by_game` · `nhl_v3_odds.nhl_v3_odds_betting_markets_by_game_sportsbook_group` · `nhl_v3_odds.nhl_v3_odds_betting_markets_by_market_type` · `nhl_v3_odds.nhl_v3_odds_betting_markets_by_market_type_sportsbook_group` · `nhl_v3_odds.nhl_v3_odds_betting_metadata` · `nhl_v3_odds.nhl_v3_odds_betting_player_props_by_game` · `nhl_v3_odds.nhl_v3_odds_betting_player_props_by_game_sportsbook_group` · `nhl_v3_odds.nhl_v3_odds_betting_results_by_market` · `nhl_v3_odds.nhl_v3_odds_betting_results_by_market_sportsbook_group` · `nhl_v3_odds.nhl_v3_odds_betting_splits_by_betting_market` · `nhl_v3_odds.nhl_v3_odds_betting_splits_by_game` · `nhl_v3_odds.nhl_v3_odds_betting_trends_by_matchup` · `nhl_v3_odds.nhl_v3_odds_betting_trends_by_team` · `nhl_v3_odds.nhl_v3_odds_in_game_odds_by_date` · `nhl_v3_odds.nhl_v3_odds_in_game_odds_by_date_sportsbook_group` · `nhl_v3_odds.nhl_v3_odds_in_game_odds_line_movement` · `nhl_v3_odds.nhl_v3_odds_in_game_odds_line_movement_sportsbook_group` · `nhl_v3_odds.nhl_v3_odds_in_game_odds_line_movement_with_resulting_sportsbook_group` · `nhl_v3_odds.nhl_v3_odds_period_game_odds_by_date` · `nhl_v3_odds.nhl_v3_odds_period_game_odds_line_movement` · `nhl_v3_odds.nhl_v3_odds_pre_game_and_period_game_odds_by_date_sportsbook_group` · `nhl_v3_odds.nhl_v3_odds_pre_game_and_period_game_odds_line_movement_sportsbook_group` · `nhl_v3_odds.nhl_v3_odds_pre_game_and_period_game_odds_line_movement_with_resulting_sportsbook_group` · `nhl_v3_odds.nhl_v3_odds_pre_game_odds_by_date` · `nhl_v3_odds.nhl_v3_odds_pre_game_odds_line_movement` · `nhl_v3_odds.nhl_v3_odds_sportsbooks_by_active` · `nhl_v3_play_by_play.nhl_v3_pbp_play_by_play_final` · `nhl_v3_play_by_play.nhl_v3_pbp_play_by_play_live_final` · `nhl_v3_play_by_play.nhl_v3_pbp_play_by_play_delta` · `nhl_v3_projections.nhl_v3_projections_dfs_slates_by_date` · `nhl_v3_projections.nhl_v3_projections_player_details_by_injured` · `nhl_v3_projections.nhl_v3_projections_projected_player_game_stats_by_date` · `nhl_v3_projections.nhl_v3_projections_starting_goaltenders_by_date` · `nhl_v3_roto_baller_premium_news.nhl_v3_news_rotoballer_premium_news` · `nhl_v3_roto_baller_premium_news.nhl_v3_news_rotoballer_premium_news_by_date` · `nhl_v3_scores.nhl_v3_scores_are_games_in_progress` · `nhl_v3_scores.nhl_v3_scores_depth_charts_goalies` · `nhl_v3_scores.nhl_v3_scores_games_by_date_final` · `nhl_v3_scores.nhl_v3_scores_games_by_date_live_final` · `nhl_v3_scores.nhl_v3_scores_games_basic_by_date_final` · `nhl_v3_scores.nhl_v3_scores_games_basic_by_date_live_final` · `nhl_v3_scores.nhl_v3_scores_news` · `nhl_v3_scores.nhl_v3_scores_news_by_date` · `nhl_v3_scores.nhl_v3_scores_player_details_by_active` · `nhl_v3_scores.nhl_v3_scores_player_details_by_active2` · `nhl_v3_scores.nhl_v3_scores_player_details_by_free_agents` · `nhl_v3_scores.nhl_v3_scores_player_details_by_free_agents2` · `nhl_v3_scores.nhl_v3_scores_players_details_by_team` · `nhl_v3_scores.nhl_v3_scores_players_profiles_by_team` · `nhl_v3_scores.nhl_v3_scores_referees` · `nhl_v3_scores.nhl_v3_scores_schedules` · `nhl_v3_scores.nhl_v3_scores_schedules_basic` · `nhl_v3_scores.nhl_v3_scores_season_current` · `nhl_v3_scores.nhl_v3_scores_stadiums` · `nhl_v3_scores.nhl_v3_scores_standings` · `nhl_v3_scores.nhl_v3_scores_team_game_logs_by_season` · `nhl_v3_scores.nhl_v3_scores_team_game_stats_by_date_live_final` · `nhl_v3_scores.nhl_v3_scores_team_profiles_all` · `nhl_v3_scores.nhl_v3_scores_team_profiles_by_active` · `nhl_v3_scores.nhl_v3_scores_team_profiles_by_season` · `nhl_v3_scores.nhl_v3_scores_team_season_stats` · `nhl_v3_scores.nhl_v3_scores_transactions` · `nhl_v3_stats.nhl_v3_stats_box_score_final` · `nhl_v3_stats.nhl_v3_stats_box_score_live_final` · `nhl_v3_stats.nhl_v3_stats_box_scores_by_date_final` · `nhl_v3_stats.nhl_v3_stats_box_scores_by_date_live_final` · `nhl_v3_stats.nhl_v3_stats_box_scores_delta_by_date` · `nhl_v3_stats.nhl_v3_stats_fantasy_points_by_date` · `nhl_v3_stats.nhl_v3_stats_line_combinations_by_season` · `nhl_v3_stats.nhl_v3_stats_player_game_logs_by_season` · `nhl_v3_stats.nhl_v3_stats_player_game_stats_by_date_final` · `nhl_v3_stats.nhl_v3_stats_player_game_stats_by_date_live_final` · `nhl_v3_stats.nhl_v3_stats_player_season_stats` · `nhl_v3_stats.nhl_v3_stats_player_season_stats_by_team` · `nhl_v3_stats.nhl_v3_stats_team_game_stats_by_date_final` · `nhl_v3_stats.nhl_v3_stats_team_stats_allowed_by_position` · `soccer_v4_headshots.soccer_v4_headshots_headshots` · `soccer_v4_odds.soccer_v4_odds_betting_events_by_date` · `soccer_v4_odds.soccer_v4_odds_betting_events_by_season` · `soccer_v4_odds.soccer_v4_odds_betting_futures_by_season` · `soccer_v4_odds.soccer_v4_odds_betting_futures_by_season_sportsbook_group` · `soccer_v4_odds.soccer_v4_odds_betting_market` · `soccer_v4_odds.soccer_v4_odds_betting_market_sportsbook_group` · `soccer_v4_odds.soccer_v4_odds_betting_markets_by_event` · `soccer_v4_odds.soccer_v4_odds_betting_markets_by_event_sportsbook_group` · `soccer_v4_odds.soccer_v4_odds_betting_markets_by_game` · `soccer_v4_odds.soccer_v4_odds_betting_markets_by_game_sportsbook_group` · `soccer_v4_odds.soccer_v4_odds_betting_markets_by_market_type` · `soccer_v4_odds.soccer_v4_odds_betting_markets_by_market_type_sportsbook_group` · `soccer_v4_odds.soccer_v4_odds_betting_metadata` · `soccer_v4_odds.soccer_v4_odds_betting_player_props_by_game` · `soccer_v4_odds.soccer_v4_odds_betting_player_props_by_game_sportsbook_group` · `soccer_v4_odds.soccer_v4_odds_in_game_odds_by_date` · `soccer_v4_odds.soccer_v4_odds_in_game_odds_by_date_sportsbook_group` · `soccer_v4_odds.soccer_v4_odds_in_game_odds_line_movement` · `soccer_v4_odds.soccer_v4_odds_in_game_odds_line_movement_sportsbook_group` · `soccer_v4_odds.soccer_v4_odds_in_game_odds_line_movement_with_resulting_sportsbook_group` · `soccer_v4_odds.soccer_v4_odds_period_game_odds_by_date` · `soccer_v4_odds.soccer_v4_odds_period_game_odds_line_movement` · `soccer_v4_odds.soccer_v4_odds_pre_game_and_period_game_odds_by_date_sportsbook_group` · `soccer_v4_odds.soccer_v4_odds_pre_game_and_period_game_odds_line_movement_sportsbook_group` · `soccer_v4_odds.soccer_v4_odds_pre_game_and_period_game_odds_line_movement_with_resulting_sportsbook_group` · `soccer_v4_odds.soccer_v4_odds_pre_game_odds_by_date` · `soccer_v4_odds.soccer_v4_odds_pre_game_odds_line_movement` · `soccer_v4_odds.soccer_v4_odds_sportsbooks_by_active` · `soccer_v4_projections.soccer_v4_projections_dfs_slates_by_date` · `soccer_v4_projections.soccer_v4_projections_player_details_by_injured` · `soccer_v4_projections.soccer_v4_projections_projected_player_game_stats_by_date` · `soccer_v4_projections.soccer_v4_projections_upcoming_dfs_slates_by_competition` · `soccer_v4_scores.soccer_v4_scores_areas_countries` · `soccer_v4_scores.soccer_v4_scores_competitions_leagues` · `soccer_v4_scores.soccer_v4_scores_games_by_competition_live_final` · `soccer_v4_scores.soccer_v4_scores_games_by_date_final` · `soccer_v4_scores.soccer_v4_scores_games_by_date_live_final` · `soccer_v4_scores.soccer_v4_scores_games_basic_by_date_final` · `soccer_v4_scores.soccer_v4_scores_games_basic_by_date_live_final` · `soccer_v4_scores.soccer_v4_scores_memberships_by_active` · `soccer_v4_scores.soccer_v4_scores_memberships_by_canceled` · `soccer_v4_scores.soccer_v4_scores_memberships_by_recently_changed` · `soccer_v4_scores.soccer_v4_scores_memberships_historical_by_team` · `soccer_v4_scores.soccer_v4_scores_player_details_by_team` · `soccer_v4_scores.soccer_v4_scores_player_profiles_by_team` · `soccer_v4_scores.soccer_v4_scores_schedules` · `soccer_v4_scores.soccer_v4_scores_schedules_basic` · `soccer_v4_scores.soccer_v4_scores_standings` · `soccer_v4_scores.soccer_v4_scores_team_game_stats_by_date_live_final` · `soccer_v4_scores.soccer_v4_scores_team_profiles_by_competition` · `soccer_v4_scores.soccer_v4_scores_team_profiles_by_season` · `soccer_v4_scores.soccer_v4_scores_team_season_stats` · `soccer_v4_scores.soccer_v4_scores_venues` · `soccer_v4_stats.soccer_v4_stats_box_score_final` · `soccer_v4_stats.soccer_v4_stats_box_score_live_final` · `soccer_v4_stats.soccer_v4_stats_box_scores_by_date_final` · `soccer_v4_stats.soccer_v4_stats_box_scores_by_date_live_final` · `soccer_v4_stats.soccer_v4_stats_box_scores_delta_by_date` · `soccer_v4_stats.soccer_v4_stats_fantasy_points_by_date` · `soccer_v4_stats.soccer_v4_stats_lineups_by_date` · `soccer_v4_stats.soccer_v4_stats_player_game_stats_by_date_final` · `soccer_v4_stats.soccer_v4_stats_player_game_stats_by_date_live_final` · `soccer_v4_stats.soccer_v4_stats_player_season_stats` · `soccer_v4_stats.soccer_v4_stats_team_game_stats_by_date_final` · `wnba_v3_scores.wnba_v3_scores_are_games_in_progress` · `wnba_v3_scores.wnba_v3_scores_betting_events_by_date` · `wnba_v3_scores.wnba_v3_scores_betting_events_by_season` · `wnba_v3_scores.wnba_v3_scores_betting_futures_by_season` · `wnba_v3_scores.wnba_v3_scores_betting_futures_by_season_sportsbook_group` · `wnba_v3_scores.wnba_v3_scores_betting_market` · `wnba_v3_scores.wnba_v3_scores_betting_market_sportsbook_group` · `wnba_v3_scores.wnba_v3_scores_betting_markets_by_event` · `wnba_v3_scores.wnba_v3_scores_betting_markets_by_event_sportsbook_group` · `wnba_v3_scores.wnba_v3_scores_betting_markets_by_game` · `wnba_v3_scores.wnba_v3_scores_betting_markets_by_game_sportsbook_group` · `wnba_v3_scores.wnba_v3_scores_betting_markets_by_market_type` · `wnba_v3_scores.wnba_v3_scores_betting_markets_by_market_type_sportsbook_group` · `wnba_v3_scores.wnba_v3_scores_betting_metadata` · `wnba_v3_scores.wnba_v3_scores_betting_player_props_by_game` · `wnba_v3_scores.wnba_v3_scores_betting_player_props_by_game_sportsbook_group` · `wnba_v3_scores.wnba_v3_scores_box_score_final` · `wnba_v3_scores.wnba_v3_scores_box_score_live_final` · `wnba_v3_scores.wnba_v3_scores_box_scores_by_date_final` · `wnba_v3_scores.wnba_v3_scores_box_scores_by_date_live_final` · `wnba_v3_scores.wnba_v3_scores_games_by_date_final` · `wnba_v3_scores.wnba_v3_scores_games_by_date_live_final` · `wnba_v3_scores.wnba_v3_scores_games_basic_by_date_final` · `wnba_v3_scores.wnba_v3_scores_games_basic_by_date_live_final` · `wnba_v3_scores.wnba_v3_scores_player_details_by_active` · `wnba_v3_scores.wnba_v3_scores_player_details_by_team` · `wnba_v3_scores.wnba_v3_scores_player_season_stats` · `wnba_v3_scores.wnba_v3_scores_player_season_stats_by_team` · `wnba_v3_scores.wnba_v3_scores_pre_game_odds_by_date` · `wnba_v3_scores.wnba_v3_scores_pre_game_odds_line_movement` · `wnba_v3_scores.wnba_v3_scores_schedules` · `wnba_v3_scores.wnba_v3_scores_schedules_basic` · `wnba_v3_scores.wnba_v3_scores_season_current` · `wnba_v3_scores.wnba_v3_scores_sportsbooks_by_active` · `wnba_v3_scores.wnba_v3_scores_stadiums` · `wnba_v3_scores.wnba_v3_scores_standings` · `wnba_v3_scores.wnba_v3_scores_team_game_stats_by_date_final` · `wnba_v3_scores.wnba_v3_scores_team_game_stats_by_date_live_final` · `wnba_v3_scores.wnba_v3_scores_team_profiles_all` · `wnba_v3_scores.wnba_v3_scores_team_season_stats`); distinguishing members *none required* 2. So `isinstance(e.error, Unauthorized)` matches only 741 of 741 operations. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares no required member on any return type, so a truncated body decodes without complaint and the hole surfaces later.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which host the `base_url` selects**, because omitting it is silently the default. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares no required member on any return type, so a truncated body decodes without complaint and the hole surfaces later.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which host the `base_url` selects**, because omitting it is silently the default. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/sportsdata/skills/python/python-integrate-sportsdata/SKILL.md b/plugins/sportsdata/skills/python/python-integrate-sportsdataio/SKILL.md similarity index 81% rename from plugins/sportsdata/skills/python/python-integrate-sportsdata/SKILL.md rename to plugins/sportsdata/skills/python/python-integrate-sportsdataio/SKILL.md index 9d26df484..6e782207b 100644 --- a/plugins/sportsdata/skills/python/python-integrate-sportsdata/SKILL.md +++ b/plugins/sportsdata/skills/python/python-integrate-sportsdataio/SKILL.md @@ -1,81 +1,97 @@ ---- -name: "python-integrate-sportsdata" -description: "MANDATORY FIRST STEP for SportsDataIO Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate SportsDataIO in Python — a full schedule of games for a given season, a list of all a team's games for a given season, game by game log of total team statistics, are any games in progress, season current, courses, dfs slates - by tournament, fantasy points - by tournament — or when a SportsDataIO Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." ---- - -# SportsDataIO Python SDK — Integration workflow (lookup layer + contract sheet) - -You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. - -## Your lookup layer - -- **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. -- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import sports_data_io, pathlib; print(pathlib.Path(sports_data_io.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) -- **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. -- **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. - -**Scope guard:** the APIMatic-generated SportsDataIO **Python SDK** (import root `sports_data_io`, distribution `sports-data-io`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. - -## Workflow - -**If the user opens with a reported SDK error or unexpected SportsDataIO behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: - -### Step 1 — Plan first (always, for any implementation work) - -Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/sports-data-io-plan.md` — that absolute path, not a location you pick later. - -**Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - -- the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `sports-data-io` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; -- a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — including which host `base_url` will select, since omitting it is silent; -- **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; -- setting up your task tracking. - -Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. - -**HARD GATE — no project-file creation or edits until:** `sports-data-io-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. - -**What the gate does not cover:** creating or activating a virtual environment, installing `sports-data-io` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. - -### Step 1c — Required reading (do this before you write any code) - -End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. - -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `python-error-handling` always (every integration writes an error boundary); `python-client-initialization` before the client is constructed; `python-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes the transport. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. - -These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a module in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. - -Before implementing, check the plan's **Assumptions & Blockers** section: - -- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `sports-data-io-plan.md` in place. -- Minor assumptions only → proceed. - -Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. - -### Step 2 — Implement from the contract sheet - -1. Read `sports-data-io-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). -3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. -4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. -5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `sports_data_io.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. - -### Step 3 — Answering pure questions - -A standalone SportsDataIO question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. - -## Anti-patterns — never do these - -- **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. -- **Never write a SportsDataIO/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Don't web-search SportsDataIO topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public SportsDataIO docs describe the REST API, not this SDK's generated surface, and the two disagree on names. -- **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. -- **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. -- **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. -- **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. - +--- +name: "python-integrate-sportsdataio" +description: "MANDATORY FIRST STEP for SportsDataIO Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate SportsDataIO in Python — a full schedule of games for a given season, a list of all a team's games for a given season, game by game log of total team statistics, are any games in progress, season current, courses, dfs slates - by tournament, fantasy points - by tournament — or when a SportsDataIO Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." +--- + +# SportsDataIO Python SDK — Integration workflow (lookup layer + contract sheet) + +You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. + +## Your lookup layer + +- **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. +- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import sports_data_io, pathlib; print(pathlib.Path(sports_data_io.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. +- **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. + +**Scope guard:** the APIMatic-generated SportsDataIO **Python SDK** (import root `sports_data_io`, distribution `sports-data-io`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. + +## Workflow + +**If the user opens with a reported SDK error or unexpected SportsDataIO behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: + +### Step 1 — Plan first (always, for any implementation work) + +Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/sports-data-io-plan.md` — that absolute path, not a location you pick later. + +**Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** + +- the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; +- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `sports-data-io` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; +- a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; +- credentials/environment verification (per the task's secret-handling rules) — including which host `base_url` will select, since omitting it is silent; +- **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; +- setting up your task tracking. + +Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. + +**HARD GATE — no project-file creation or edits until:** `sports-data-io-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. + +**What the gate does not cover:** creating or activating a virtual environment, installing `sports-data-io` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +### Step 1c — Required reading (do this before you write any code) + +End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. + +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `python-error-handling` always (every integration writes an error boundary); `python-client-initialization` before the client is constructed; `python-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes the transport. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. + +These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a module in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. + +Before implementing, check the plan's **Assumptions & Blockers** section: + +- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `sports-data-io-plan.md` in place. +- Minor assumptions only → proceed. + +Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. + +### Step 2 — Implement from the contract sheet + +1. Read `sports-data-io-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. +2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). +3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. +4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. +5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `sports_data_io.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. + +### Step 3 — Answering pure questions + +A standalone SportsDataIO question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. + +## Anti-patterns — never do these + +- **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. +- **Never write a SportsDataIO/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** +- **Don't web-search SportsDataIO topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public SportsDataIO docs describe the REST API, not this SDK's generated surface, and the two disagree on names. +- **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. +- **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. +- **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. +- **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. + diff --git a/plugins/sportsdata/skills/python/python-models/SKILL.md b/plugins/sportsdata/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/sportsdata/skills/python/python-models/SKILL.md +++ b/plugins/sportsdata/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/sportsdata/skills/python/python-models/reference.md b/plugins/sportsdata/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/sportsdata/skills/python/python-models/reference.md +++ b/plugins/sportsdata/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/sportsdata/skills/python/python-testing/SKILL.md b/plugins/sportsdata/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/sportsdata/skills/python/python-testing/SKILL.md +++ b/plugins/sportsdata/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/sportsdata/skills/typescript/typescript-authentication/SKILL.md b/plugins/sportsdata/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/sportsdata/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/sportsdata/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/sportsdata/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/sportsdata/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/sportsdata/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/sportsdata/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/sportsdata/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/sportsdata/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/sportsdata/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/sportsdata/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/sportsdata/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/sportsdata/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/sportsdata/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/sportsdata/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/sportsdata/skills/typescript/typescript-error-handling/SKILL.md b/plugins/sportsdata/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/sportsdata/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/sportsdata/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/sportsdata/skills/typescript/typescript-getting-started/SKILL.md b/plugins/sportsdata/skills/typescript/typescript-getting-started/SKILL.md index 5d75d823a..ac50e40d8 100644 --- a/plugins/sportsdata/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/sportsdata/skills/typescript/typescript-getting-started/SKILL.md @@ -5,7 +5,7 @@ description: "SportsDataIO TypeScript SDK identity and lookup layer (TypeScript/ # Getting started with the SportsDataIO TypeScript SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing SportsDataIO TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-sportsdata` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing SportsDataIO TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-sportsdataio` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated TypeScript SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `typescript-client-initialization`, `typescript-authentication`, `typescript-calling-endpoints`, `typescript-models`, `typescript-error-handling`, `typescript-configuration-resilience` and `typescript-testing`. @@ -13,22 +13,24 @@ This is the **SDK-specific** entry point. For general patterns that apply to any ## SDK identity -Verified against `package.json` and `sdk-map.md` of the generated package at version `1.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. +Verified against `package.json` and `sdk-map.md` of the generated package at version `1.0.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. | Fact | Value | | --- | --- | | API | SportsDataIO | | Package name (what you install, and what you import) | `sports-data-io` — **not on npm**; built from source (see *Install*) | | Import specifier | `sports-data-io` — the package root is the **only** entry; deep imports do not resolve | -| Version | `1.0` (API spec version `1.0`) | +| Source repository | https://github.com/context-plugins/sportsdata-typescript-sdk (branch `main` — the ref this map documents) | +| Version | `1.0.0` (API spec version `1.0`) | | Client class | `SportsDataIoClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new SportsDataIoClient(clientOptions: Partial = {})` — **every** field is optional, so `new SportsDataIoClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `apiKeyHeader` · `apiKeyQuery`. `timeout` defaults to `60_000` ms | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new SportsDataIoClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new SportsDataIoClient()` compiles. Fields: `serverOptions` · `retry` · `fetch` · `apiKeyHeader` · `apiKeyQuery`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **API key (header)** — set `ClientOptions.apiKeyHeader`
**API key (query)** — set `ClientOptions.apiKeyQuery` | -| Environments | 1 environment (`ServerEnvironment.Production` *(default)*) × 1 server group | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`), defaulting to `https://api.sportsdata.io` | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Environments | no selector — one environment, so none is emitted; 1 server group | +| Base-URL config | `serverOptions.baseUrl` (`src/servers.ts`), defaulting to `https://api.sportsdata.io` | +| Log environment variable | `SPORTS_DATA_IO_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Surface | 741 operations · 50 resources · 406 models · 5 open enums · 741 per-operation error subclasses | @@ -37,22 +39,22 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/sportsdata-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 411 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, 411 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { SportsDataIoClient, ServerEnvironment, ResponseError, SportsDataIoError } from "sports-data-io"; +import { SportsDataIoClient, ApiError, SportsDataIoError } from "sports-data-io"; import type { ClientOptions, Area } from "sports-data-io"; ``` @@ -65,21 +67,19 @@ Things the specifier alone will not tell you: Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, every model type) must be imported with `import type`. Under `exactOptionalPropertyTypes`, **omit or spread** an absent optional field rather than assigning `undefined` to it. -## Environments +## Hosts -`ClientOptions.serverEnvironment` selects one environment for the whole client (`src/servers.ts`). `ServerEnvironment` is a `const` object with a derived union type — not a TypeScript `enum` — and unlike the model enums it is **closed**, so only its declared members are assignable. +This API declares one environment, so the SDK has **no environment selector** — there is no `ServerEnvironment` type and no field to set (`src/servers.ts`). The host below is the only one the SDK knows, and the override in the last column is the only way to reach another. -| Group | Environment | Base URL | Override at | -| --- | --- | --- | --- | -| `default` | `production` *(default)* | `https://api.sportsdata.io` | `serverOptions.default.production.baseUrl` | +| Group | Base URL | Override at | +| --- | --- | --- | +| `default` | `https://api.sportsdata.io` | `serverOptions.baseUrl` | Consequences to state on every contract sheet that touches configuration: -- Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- ⚠ **This SDK declares exactly one environment**, so reaching any other host is a `serverOptions` override, not an environment value — `serverOptions: { default: { production: { baseUrl: "…" } } }`. Note the key is still `production`: the environment name and the host it points at are now decoupled, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the deployment's intent explicit where the client is built, and verify it. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- ⚠ **This SDK declares exactly one environment**, so it emits no `ServerEnvironment` at all and reaching any other host is a `serverOptions` base-URL override — `serverOptions: { baseUrl: "…" }`. Nothing in the options then records **which** deployment this is, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the intent explicit where the client is built, and verify it. +- An override merges with the built-in default **key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. ## Auth pattern (2 schemes) @@ -103,7 +103,7 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. See `typescript-authentication` for the full picture. @@ -164,7 +164,7 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | `client.mmaV3Odds` | `MmaV3Odds` | 10 | `mmaV3OddsBettingEventsByEvent` · `mmaV3OddsBettingEventsUpcoming` · `mmaV3OddsBettingFighterPropsByEvent` · `mmaV3OddsBettingMarket` · `mmaV3OddsBettingMarketsByEvent` · `mmaV3OddsBettingMarketsByMarketType` · `mmaV3OddsBettingMetadata` · `mmaV3OddsEventFightOdds` · `mmaV3OddsEventFightOddsLineMovement` · `mmaV3OddsSportsbooksByActive` | | `client.mmaV3Stats` | `MmaV3Stats` | 4 | `mmaV3StatsFightBasicFinal` · `mmaV3StatsFightBasicLiveFinal` · `mmaV3StatsFightFinal` · `mmaV3StatsFightLiveFinal` | -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** A name that collides inside its namespace is promoted to `RequestParams` instead — no operation in this SDK takes that spelling at this version. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -172,7 +172,7 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: @@ -181,11 +181,11 @@ Locate the installed package before you rely on a lookup: node -e "console.log(require.resolve('sports-data-io/package.json'))" ``` -Failing that it is at `node_modules/sports-data-io/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/sports-data-io/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/sports-data-io/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -193,7 +193,7 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/sports-data-io/`: @@ -203,13 +203,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -223,30 +225,38 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `SPORTS_DATA_IO_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new SportsDataIoClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new SportsDataIoClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) 2. **Authentication** — load **typescript-authentication** before you set credentials. The 2 schemes are `apiKeyHeader`, `apiKeyQuery` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **One operation resolves to `undefined` (`wnbaV3Scores.wnbaV3ScoresStandings`)**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `SportsDataIoError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `SportsDataIoError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `SPORTS_DATA_IO_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK Beyond the usual signatures and model members, a contract sheet for the SportsDataIO TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. -1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment, `ServerEnvironment.Production`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. +1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment and emits no `ServerEnvironment`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. 2. **One operation resolves to `undefined` (`wnbaV3Scores.wnbaV3ScoresStandings`)** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 741 of 741 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. **That `Event` imported from this package is a model type, not the global of that name** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `SportsDataIoError` for the same reason. -10. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 741 of 741 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. **That `Event` imported from this package is a model type, not the global of that name** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `SportsDataIoError` for the same reason. +12. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/sportsdata/skills/typescript/typescript-integrate-sportsdata/SKILL.md b/plugins/sportsdata/skills/typescript/typescript-integrate-sportsdataio/SKILL.md similarity index 51% rename from plugins/sportsdata/skills/typescript/typescript-integrate-sportsdata/SKILL.md rename to plugins/sportsdata/skills/typescript/typescript-integrate-sportsdataio/SKILL.md index f95e985cb..eabb6bdf8 100644 --- a/plugins/sportsdata/skills/typescript/typescript-integrate-sportsdata/SKILL.md +++ b/plugins/sportsdata/skills/typescript/typescript-integrate-sportsdataio/SKILL.md @@ -1,6 +1,6 @@ --- -name: "typescript-integrate-sportsdata" -description: "MANDATORY FIRST STEP for SportsDataIO TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate SportsDataIO in TypeScript/Node — nfl v3 scores are games in progress, nfl v3 scores bye weeks, nfl v3 scores depth charts all, nfl v3 scores depth charts by active, nfl v3 scores depth charts by week, nfl v3 scores games by date final, or when the SportsDataIO TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `sports-data-io` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (sports-data-io-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares only ServerEnvironment.Production, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." +name: "typescript-integrate-sportsdataio" +description: "MANDATORY FIRST STEP for SportsDataIO TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate SportsDataIO in TypeScript/Node — nfl v3 scores are games in progress, nfl v3 scores bye weeks, nfl v3 scores depth charts all, nfl v3 scores depth charts by active, nfl v3 scores depth charts by week, nfl v3 scores games by date final, or when the SportsDataIO TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `sports-data-io` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (sports-data-io-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares one environment and no ServerEnvironment, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." --- # SportsDataIO TypeScript SDK — Integration workflow (lookup layer + contract sheet) @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/sports-data-io/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('sports-data-io/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('sports-data-io/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. @@ -23,14 +23,14 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/sports-data-io-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/sports-data-io-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, **module format — ESM or CJS, which changes how the SDK is imported and whether `instanceof` on its error classes is trustworthy**, and the runtime: Node, a bundled browser app, or an edge runtime) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - **establish the toolchain before you need it**: the package manager, whether `sports-data-io` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; - a baseline run of the project's checks (`tsc --noEmit`, the test runner, the linter — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment (`ServerEnvironment.Production`), so **any other host is a `serverOptions` base-URL override rather than an environment value**; +- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment, so it emits no `ServerEnvironment` at all and **any other host is a `serverOptions` base-URL override rather than an environment value**; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your credential may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; - setting up your task tracking. @@ -40,11 +40,85 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** installing `sports-data-io` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. @@ -58,13 +132,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp ### Step 2 — Implement from the contract sheet 1. Read `sports-data-io-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment — `ServerEnvironment.Production` — so a different host is a `serverOptions` override under a key still named after `production`: the environment name and the host it points at are decoupled, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. +2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment and emits no selector for it, so a different host is a `serverOptions` base-URL override. Nothing in the options then records **which** deployment this is, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. 3. **Build the client once and hold it.** Resources are memoized lazy getters on it, so rebuilding one per request throws that state away. There is no `close()` — it owns a `fetch`, not a pool. 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from the **one operation that resolves to `undefined`** (`wnbaV3Scores.wnbaV3ScoresStandings`). `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `SportsDataIoError` family, told apart by `err.kind`. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `SPORTS_DATA_IO_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions @@ -74,7 +150,7 @@ A standalone SportsDataIO question with no code change: look it up in `typescrip - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. - **Never write any SportsDataIO or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. - **Don't web-search SportsDataIO topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public SportsDataIO docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. diff --git a/plugins/sportsdata/skills/typescript/typescript-models/SKILL.md b/plugins/sportsdata/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/sportsdata/skills/typescript/typescript-models/SKILL.md +++ b/plugins/sportsdata/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/sportsdata/skills/typescript/typescript-models/reference.md b/plugins/sportsdata/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/sportsdata/skills/typescript/typescript-models/reference.md +++ b/plugins/sportsdata/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/sportsdata/skills/typescript/typescript-testing/SKILL.md b/plugins/sportsdata/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/sportsdata/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/sportsdata/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/spotify/.claude-plugin/plugin.json b/plugins/spotify/.claude-plugin/plugin.json index 90af25c59..6a7204ffe 100644 --- a/plugins/spotify/.claude-plugin/plugin.json +++ b/plugins/spotify/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "spotify", "displayName": "Spotify Web API", - "version": "0.1.2", + "version": "0.2.0", "description": "Integrate and consume the APIMatic-generated Spotify Web API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/spotify/.cursor-plugin/plugin.json b/plugins/spotify/.cursor-plugin/plugin.json index 90af25c59..6a7204ffe 100644 --- a/plugins/spotify/.cursor-plugin/plugin.json +++ b/plugins/spotify/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "spotify", "displayName": "Spotify Web API", - "version": "0.1.2", + "version": "0.2.0", "description": "Integrate and consume the APIMatic-generated Spotify Web API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/spotify/plugin.json b/plugins/spotify/plugin.json index 90af25c59..6a7204ffe 100644 --- a/plugins/spotify/plugin.json +++ b/plugins/spotify/plugin.json @@ -1,7 +1,7 @@ { "name": "spotify", "displayName": "Spotify Web API", - "version": "0.1.2", + "version": "0.2.0", "description": "Integrate and consume the APIMatic-generated Spotify Web API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/spotify/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/spotify/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..8c5d97636 100644 --- a/plugins/spotify/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/spotify/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,67 +1,29 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Spotify Web API .NET SDK in C# — this API authenticates with OAuth 2.0 client credentials and the OAuth 2.0 authorization-code grant, and this skill gives the credential each scheme takes, the SpotifyWebApiClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Spotify Web API .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with OAuth 2.0 client credentials and the OAuth 2.0 authorization-code grant. APIMatic surfaces each scheme as a **nullable credentials property** on `SpotifyWebApiClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) - -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). - -## Basic auth - -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` - -Sends `Authorization: Basic base64(username:password)`. - -## Bearer token - -Set the configured token property on the options class to your access-token string: - -```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; -``` - -Sends `Authorization: Bearer ACCESS_TOKEN`. +| Scheme | Property on `SpotifyWebApiClientOptions` | Credential | +| --- | --- | --- | +| OAuth 2.0 client credentials | `Oauth20ClientCredentials` | `OAuth2ClientCredentials` | +| OAuth 2.0 authorization code | `Oauth20` | `OAuth2AuthorizationCodeCredentials` | -## API key (header, query, or cookie) +**That table is the entire authentication surface of this SDK.** `SpotifyWebApi.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: +## OAuth 2.0 — client credentials (machine-to-machine) -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` +**API Provider Notes:** -## OAuth 2.0 — client credentials (machine-to-machine) +> Server-to-server OAuth 2.0 client credentials flow. Tokens obtained this way carry no user context and no scopes, so they only work on endpoints that read public catalog data. Verified against the live token endpoint. ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; +using SpotifyWebApi.Core.Authentication.OAuth2.ClientCredentials; -options.{OAuthProperty} = new OAuth2ClientCredentials +options.Oauth20ClientCredentials = new OAuth2ClientCredentials { ClientId = "...", ClientSecret = "...", @@ -69,18 +31,21 @@ options.{OAuthProperty} = new OAuth2ClientCredentials }; ``` -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. +The SDK fetches the token from `https://api.spotify.com/v1/api/token` and caches it, acquiring a fresh one when it expires; on a `401` it invalidates the cached token and re-acquires. The token request carries the client id and secret as HTTP Basic credentials, so `ClientSecret` is required. ## OAuth 2.0 — authorization code (3-legged, with PKCE) +**API Provider Notes:** + +> Spotify supports OAuth 2.0 for authenticating all API requests. + ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; +using SpotifyWebApi.Core.Authentication.OAuth2.AuthorizationCode; -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials +options.Oauth20 = new OAuth2AuthorizationCodeCredentials { ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE + ClientSecret = "...", RedirectUri = "https://app.example.com/callback", Scope = "...", // optional State = "...", // optional CSRF token @@ -94,152 +59,91 @@ options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials }; ``` -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. +The SDK sends the user to `https://api.spotify.com/v1/authorize`, exchanges the returned code for a token at `https://api.spotify.com/v1/api/token`, and refreshes it when it expires; if the refresh fails, it invokes `PromptForAuthorizationCode` again to re-authorize. -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: +`PromptForAuthorizationCode` runs inside the call that needs a new token, under the scheme's fetch lock, so it must not make an SDK call that uses this scheme. It counts against the per-attempt `options.Retry.Timeout`, 100 s by default, and when the call is retryable (by default a `GET`, `HEAD`, `PUT` or `OPTIONS`) a timed-out attempt is retried and prompts again. A prompt that throws anything but `OperationCanceledException` fails the call with `AuthSchemeException`. `State` is sent as you set it and never checked, so your redirect handler must verify it. -| PKCE | token request built | no secret ⇒ | -| --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | +⚠ **`ClientSecret` is required here — PKCE does not make it optional.** This SDK builds the token request with `OAuth2AuthorizationCodeStrategy.ForBasicAuthRequest`, which sends the client id and secret as HTTP Basic credentials on the exchange. Omit it and the call fails with an `AuthSchemeException` wrapping an `InvalidOperationException`, at one of two very different moments: -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. +- **PKCE disabled** (`Pkce = null`) — throws *before* `PromptForAuthorizationCode` runs. +- **PKCE enabled** (the default `S256`) — throws *after* the prompt has already run. The user completes a full browser round-trip, and only then does the exchange fail. This is the expensive one. -## OAuth 2.0 — resource owner password +The public-client carve-out — a secret-less PKCE flow — belongs to the form-body token request, which this SDK does not use. Do not plan around it here. -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; +## Token caching & refresh -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` +- Tokens are cached in-memory, **per client instance**, and reused until **30s** before expiry. +- **Only the authorization-code grant refreshes.** It is the one grant wired to `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. The other grants above re-run in full when the token expires — and a `refresh_token` in their token response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do not infer refresh behaviour from what the provider returns. +- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: a `401` there is an interactive re-authorization, not a silent refresh. **Plan for that in a non-interactive host** — a background worker will block on a prompt nobody can answer. +- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of a fresh token. -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. +⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** `expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to "not expired" when it is absent — so the first token is cached for the life of the client and the only thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. ### Supplying your own token source -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. +The token strategy is a public extension point: `SpotifyWebApiClientOptions` exposes a strategy property beside each credentials property, and the generated client falls back to the built-in strategy only when you leave it null. The strategy is used only when the credentials property beside it is also set, and `GetToken` receives that object. + +| Grant | Strategy property on `SpotifyWebApiClientOptions` | Type | +| --- | --- | --- | +| Client credentials | `Oauth20ClientCredentialsTokenStrategy` | `IOAuth2TokenStrategy` | +| Authorization code | `Oauth20TokenStrategy` | `IOAuth2RefreshableTokenStrategy` | ```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) +options.Oauth20ClientCredentials = new OAuth2ClientCredentials { /* its required members */ }; +options.Oauth20ClientCredentialsTokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) ``` -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: +Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test double. The per-client in-memory cache above still wraps whatever you return. -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). +## Combining the schemes -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. +This SDK generates more than one credentials property, and its operations compose them. You configure the composition by setting the relevant properties on `SpotifyWebApiClientOptions` — the generated client wires the rest. -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +**OR (`AuthSchemeAny`) — alternatives, tried in order.** Schemes with **no credentials configured are skipped, not tried**; the first configured scheme that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails. Configure exactly the one you intend to use — an extra credential set "just in case" changes which scheme authenticates the call. **Which one that is, is not a preference:** each scheme's **API Provider Notes** above are where the provider states when its scheme applies. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `SpotifyWebApiClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("SpotifyWebApiClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.ClientId)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "SpotifyWebApiClient:ClientId is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"SpotifyWebApiClient:ClientId is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +Check every value **the scheme you chose** needs. The rows below are not one checklist: the schemes above are alternatives, so a row belonging to a scheme you are not using needs no value at all, and demanding one at startup blocks a boot that would have worked. Validate the rows whose property you set, and no others: + +| Value to validate | Feeds `SpotifyWebApiClientOptions` property | +| --- | --- | +| `ClientId` | `Oauth20ClientCredentials` | +| `ClientSecret` | `Oauth20ClientCredentials` | +| `ClientId` | `Oauth20` | +| `ClientSecret` | `Oauth20` | +| `RedirectUri` | `Oauth20` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddSpotifyWebApiClient(options => ...)` callback when registering via DI. `SpotifyWebApiClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddSpotifyWebApiClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/spotify/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/spotify/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/spotify/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/spotify/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/spotify/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/spotify/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/spotify/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/spotify/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/spotify/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/spotify/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/spotify/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/spotify/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/spotify/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/spotify/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/spotify/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/spotify/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException
` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/spotify/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/spotify/skills/dotnet/dotnet-getting-started/SKILL.md index 247328ea3..db32c93ac 100644 --- a/plugins/spotify/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/spotify/skills/dotnet/dotnet-getting-started/SKILL.md @@ -11,7 +11,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity @@ -26,7 +26,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any | Auth | **OAuth 2.0** authorization code — set `Oauth20`
**OAuth 2.0** client credentials — set `Oauth20ClientCredentials` | | Environments | `ServerEnvironment.Production` *(default)* → `https://api.spotify.com/v1`
`ServerEnvironment.Environment2` → `https://accounts.spotify.com` | | Log environment variable | `SPOTIFYWEBAPICLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-spotify-web-api` makes that binding); the **Integration workflow** below says which governs which step. @@ -44,22 +44,24 @@ Models, enums, union types and the generated error types each sit in their own c ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Spotify Web API, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/SpotifyWebApi/Models/`, `src/SpotifyWebApi/Errors/` and `src/SpotifyWebApi/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/SpotifyWebApi/Models/`, enums `src/SpotifyWebApi/Models/Enums/`, typed errors `src/SpotifyWebApi/Errors/`, request records `src/SpotifyWebApi/Requests/`, or `src/SpotifyWebApi/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: @@ -69,7 +71,7 @@ Keep lookups cheap — the rules that keep a session's context small: ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/spotify-web-api-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/spotify-web-api-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. **Clone once, up front**, into a fresh timestamped folder under `/spotify-web-api-sdk-src/`, and reuse that folder for the rest of your session: @@ -87,30 +89,31 @@ git clone --filter=blob:none --branch main https://github.com/context-plugins/sp # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/spotify-web-api-sdk-src/`, **never** in the project repo, and its path never goes into `spotify-web-api-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `spotify-web-api-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `SpotifyWebApiClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/SpotifyWebApi/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/SpotifyWebApi/Models/` (+ `src/SpotifyWebApi/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/SpotifyWebApi/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/SpotifyWebApi/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/SpotifyWebApi/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/SpotifyWebApi/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/SpotifyWebApi/Servers/`, `src/SpotifyWebApi/SpotifyWebApiClient.cs`, `src/SpotifyWebApi/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Spotify Web API may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything @@ -126,7 +129,7 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/SpotifyWebApi/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step @@ -134,9 +137,9 @@ You loaded every companion the sheet named before starting (per `dotnet-integrat 1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new SpotifyWebApiClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) 2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `SpotifyWebApiClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/spotify/skills/dotnet/dotnet-integrate-spotify-web-api/SKILL.md b/plugins/spotify/skills/dotnet/dotnet-integrate-spotify-web-api/SKILL.md index 6c0c39663..e35d355b7 100644 --- a/plugins/spotify/skills/dotnet/dotnet-integrate-spotify-web-api/SKILL.md +++ b/plugins/spotify/skills/dotnet/dotnet-integrate-spotify-web-api/SKILL.md @@ -11,7 +11,7 @@ You are the Spotify Web API .NET SDK specialist for this task — one agent for ## Grounding rules -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `spotify-web-api-plan.md` — the plan must stay portable. +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `spotify-web-api-plan.md` never carries its path — the plan must stay portable. - **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. - **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. - **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. @@ -29,7 +29,7 @@ Your FIRST deliverable is `spotify-web-api-plan.md` at `/spot #### How to ground (map-first, one pass) 1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. 3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. 4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. 5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. @@ -38,7 +38,7 @@ Your FIRST deliverable is `spotify-web-api-plan.md` at `/spot These need no SDK knowledge and touch no project file, so do them before or alongside the map work: -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); - the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; - locating the SDK reference and its version in the project; @@ -54,10 +54,23 @@ Never use this prerequisite work as a "head start" on implementation: **creating #### The `spotify-web-api-plan.md` format (keep it tight — tables, not prose) 1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + 3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. 6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. 7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. @@ -67,12 +80,46 @@ Never use this prerequisite work as a "head start" on implementation: **creating | 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | | 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | | 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | | 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | | 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | | 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. @@ -95,14 +142,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. 3. After every change: `dotnet build`; fix non-SDK errors yourself. 4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `SpotifyWebApi.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. ### Step 4 — Fixing SDK errors (map-first, in place) A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `SpotifyWebApi.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/SpotifyWebApi/Models/{Type}.cs`, `src/SpotifyWebApi/Models/Enums/{Type}.cs`, `src/SpotifyWebApi/Errors/{Type}.cs`, `src/SpotifyWebApi/Requests/{Type}.cs`, or `src/SpotifyWebApi/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. 3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". 4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). 5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. @@ -120,6 +168,6 @@ A standalone Spotify Web API question with no code change: load `dotnet-getting- - **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. - **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. - **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search Spotify Web API for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `SpotifyWebApi` is what the generated source declares, not what public docs describe. +- **Do not web-search Spotify Web API for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `SpotifyWebApi` is what the generated source declares, not what public docs describe. - **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. diff --git a/plugins/spotify/skills/dotnet/dotnet-models/SKILL.md b/plugins/spotify/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/spotify/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/spotify/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/spotify/skills/dotnet/dotnet-models/reference.md b/plugins/spotify/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/spotify/skills/dotnet/dotnet-models/reference.md +++ b/plugins/spotify/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/spotify/skills/dotnet/dotnet-testing/SKILL.md b/plugins/spotify/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/spotify/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/spotify/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/spotify/skills/python/python-authentication/SKILL.md b/plugins/spotify/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/spotify/skills/python/python-authentication/SKILL.md +++ b/plugins/spotify/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/spotify/skills/python/python-calling-endpoints/SKILL.md b/plugins/spotify/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/spotify/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/spotify/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/spotify/skills/python/python-client-initialization/SKILL.md b/plugins/spotify/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/spotify/skills/python/python-client-initialization/SKILL.md +++ b/plugins/spotify/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/spotify/skills/python/python-configuration-resilience/SKILL.md b/plugins/spotify/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/spotify/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/spotify/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/spotify/skills/python/python-error-handling/SKILL.md b/plugins/spotify/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/spotify/skills/python/python-error-handling/SKILL.md +++ b/plugins/spotify/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/spotify/skills/python/python-event-streams/SKILL.md b/plugins/spotify/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/spotify/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/spotify/skills/python/python-file-handling/SKILL.md b/plugins/spotify/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/spotify/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/spotify/skills/python/python-getting-started/SKILL.md b/plugins/spotify/skills/python/python-getting-started/SKILL.md index 900091f3b..55fbe7882 100644 --- a/plugins/spotify/skills/python/python-getting-started/SKILL.md +++ b/plugins/spotify/skills/python/python-getting-started/SKILL.md @@ -25,12 +25,12 @@ Verified against `spotify_web_api/` and `pyproject.toml` of the generated packag | Version | `2024.3.3` | | Sync client class | `SpotifyWebApiClient` (alias `Client`) | | Async client class | `AsyncSpotifyWebApiClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `environment` · `base_url` · `timeout` (default `30.0`) · `oauth_2_0` · `oauth_2_0_token_source` · `oauth_2_0_client_credentials` · `oauth_2_0_client_credentials_token_source`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Client construction | **keyword-only**: `environment` · `base_url` · `timeout` (default `30.0`) · `retry_options` · `oauth_2_0` · `oauth_2_0_token_source` · `oauth_2_0_client_credentials` · `oauth_2_0_client_credentials_token_source`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | **OAuth 2.0** authorization code — set `oauth_2_0` · **OAuth 2.0** client credentials — set `oauth_2_0_client_credentials` | | Environments | 2 environments selected by `environment` (default `"production"`), overridable with `base_url` | | Base-URL config | `ServerConfig` (`spotify_web_api/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | | Surface | 138 operations across 15 controllers · 129 models · 4 unions · 18 enums · 89 per-operation error unions | @@ -39,13 +39,18 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash pip install "spotify-web-api @ git+https://github.com/context-plugins/spotify-python-sdk.git@main" +python -c "import spotify_web_api, pathlib; print(pathlib.Path(spotify_web_api.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules @@ -152,15 +157,15 @@ The SDK map's controller table carries the same counts and links to a page per c The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `spotify_web_api/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `spotify_web_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `spotify_web_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/spotify-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. Every `Source` path on the map is relative to that SDK root, so `spotify_web_api/models/album_base.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. @@ -180,7 +185,7 @@ Read the one module that owns the fact **inside the installed package**. Locate python -c "import spotify_web_api, pathlib; print(pathlib.Path(spotify_web_api.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/spotify_web_api` on Windows, `.venv/lib/python3.*/site-packages/spotify_web_api` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/spotify_web_api` on Windows, `.venv/lib/python3.*/site-packages/spotify_web_api` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -188,7 +193,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/sp | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -198,7 +203,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/sp | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | | An operation's error mapper (status → schema) | `errors/_error.py` | @@ -215,13 +220,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The two schemes are `oauth_2_0=` and `oauth_2_0_client_credentials=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; the token is fetched lazily and cached by the client; and a **failed token fetch** raises something other than what a caller expects and **bypasses the non-raising response mode** entirely. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; **43 operations return `None`**, so `with_raw_response` is the only way to observe their status code; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`/`…OrInt`), so an unknown wire value passes through as a plain `str`/`int` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -238,8 +243,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 4. `BadRequest1` — 1 operation (`audiobooks.get_an_audiobook`); distinguishing members `error` 5. `NotFound1` — 1 operation (`audiobooks.get_an_audiobook`); distinguishing members `error` 6. So `isinstance(e.error, Forbidden1)` matches only 138 of 138 operations. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 56 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"` of the 2 declared. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 56 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"` of the 2 declared. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/spotify/skills/python/python-integrate-spotify-web-api/SKILL.md b/plugins/spotify/skills/python/python-integrate-spotify-web-api/SKILL.md index d55d08615..44fd6b4cf 100644 --- a/plugins/spotify/skills/python/python-integrate-spotify-web-api/SKILL.md +++ b/plugins/spotify/skills/python/python-integrate-spotify-web-api/SKILL.md @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `python-getting-started` is your **lookup layer - **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import spotify_web_api, pathlib; print(pathlib.Path(spotify_web_api.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import spotify_web_api, pathlib; print(pathlib.Path(spotify_web_api.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) - **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. @@ -40,6 +40,21 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** creating or activating a virtual environment, installing `spotify-web-api` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. @@ -62,8 +77,9 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. 4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `spotify_web_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `spotify_web_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. ### Step 3 — Answering pure questions diff --git a/plugins/spotify/skills/python/python-models/SKILL.md b/plugins/spotify/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/spotify/skills/python/python-models/SKILL.md +++ b/plugins/spotify/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/spotify/skills/python/python-models/reference.md b/plugins/spotify/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/spotify/skills/python/python-models/reference.md +++ b/plugins/spotify/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/spotify/skills/python/python-testing/SKILL.md b/plugins/spotify/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/spotify/skills/python/python-testing/SKILL.md +++ b/plugins/spotify/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/spotify/skills/typescript/typescript-authentication/SKILL.md b/plugins/spotify/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/spotify/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/spotify/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/spotify/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/spotify/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/spotify/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/spotify/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/spotify/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/spotify/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/spotify/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/spotify/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/spotify/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/spotify/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/spotify/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/spotify/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/spotify/skills/typescript/typescript-error-handling/SKILL.md b/plugins/spotify/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/spotify/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/spotify/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/spotify/skills/typescript/typescript-getting-started/SKILL.md b/plugins/spotify/skills/typescript/typescript-getting-started/SKILL.md index 2a10fea3f..dcb7875d8 100644 --- a/plugins/spotify/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/spotify/skills/typescript/typescript-getting-started/SKILL.md @@ -20,15 +20,17 @@ Verified against `package.json` and `sdk-map.md` of the generated package at ver | API | Spotify Web API | | Package name (what you install, and what you import) | `spotify-web-api` — **not on npm**; built from source (see *Install*) | | Import specifier | `spotify-web-api` — the package root is the **only** entry; deep imports do not resolve | +| Source repository | https://github.com/context-plugins/spotify-web-api-typescript-sdk (branch `main` — the ref this map documents) | | Version | `2024.3.3` (API spec version `2024.3.3`) | | Client class | `SpotifyWebApiClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new SpotifyWebApiClient(clientOptions: Partial = {})` — **every** field is optional, so `new SpotifyWebApiClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `oauth20` · `oauth20Strategy` · `oauth20ClientCredentials` · `oauth20ClientCredentialsStrategy`. `timeout` defaults to `60_000` ms | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new SpotifyWebApiClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new SpotifyWebApiClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `retry` · `fetch` · `oauth20` · `oauth20Strategy` · `oauth20ClientCredentials` · `oauth20ClientCredentialsStrategy`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **OAuth 2 authorization code** — set `ClientOptions.oauth20`
**OAuth 2 client credentials** — set `ClientOptions.oauth20ClientCredentials` | | Environments | 2 environments (`ServerEnvironment.Production` *(default)*, `ServerEnvironment.Environment2`) × 1 server group | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`), defaulting to `https://api.spotify.com/v1` | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Base-URL config | `serverOptions.baseUrl` (`src/servers.ts`), defaulting to `https://api.spotify.com/v1` | +| Log environment variable | `SPOTIFY_WEB_API_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Surface | 138 operations · 15 resources · 129 models · 18 open enums · 4 unions · 138 per-operation error subclasses | @@ -37,22 +39,22 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/spotify-web-api-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 151 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 151 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { SpotifyWebApiClient, ServerEnvironment, ResponseError, SpotifyWebApiError } from "spotify-web-api"; +import { SpotifyWebApiClient, ServerEnvironment, ApiError, SpotifyWebApiError } from "spotify-web-api"; import type { ClientOptions, AlbumBase } from "spotify-web-api"; ``` @@ -70,15 +72,15 @@ Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, | Group | Environment | Base URL | Override at | | --- | --- | --- | --- | -| `default` | `production` *(default)* | `https://api.spotify.com/v1` | `serverOptions.default.production.baseUrl` | -| `default` | `environment2` | `https://accounts.spotify.com` | `serverOptions.default.environment2.baseUrl` | +| `default` | `production` *(default)* | `https://api.spotify.com/v1` | `serverOptions.baseUrl` | +| `default` | `environment2` | `https://accounts.spotify.com` | `serverOptions.baseUrl` | Consequences to state on every contract sheet that touches configuration: - Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. +- An environment value the SDK does not know throws `ConfigurationError` **from the constructor** — every server group is resolved once, as the client is built — so no operation method throws synchronously. ## Auth pattern (2 schemes) @@ -102,9 +104,9 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. -**OAuth 2 fetches and caches its own token.** The token request goes through **the same client** — same `timeout`, same `fetch` — sends a form-urlencoded body, and decodes the response against a schema rather than casting it. A token is cached until shortly before it expires; a response with no `expires_in` is treated as never expiring (RFC 6749 §5.1); concurrent callers share one in-flight fetch. **A refused token endpoint rejects with `AuthError`**, wrapping the underlying `ResponseError` as `cause` — so a bad secret never looks like the business call failing, and `AuthError` is *not* a `ResponseError`. +**OAuth 2 fetches and caches its own token.** The token request goes through **the same client** — same `fetch`, same `retry` policy — sends a form-urlencoded body, and decodes the response against a schema rather than casting it. A token is cached until shortly before it expires; a response with no `expires_in` is treated as never expiring (RFC 6749 §5.1); concurrent callers share one in-flight fetch. **A refused token endpoint rejects with `AuthError`**, wrapping the underlying `ApiError` as `cause` — so a bad secret never looks like the business call failing, and `AuthError` is *not* an `ApiError`. The token request is itself a `POST` with its own timeout, retried only when `httpMethodsToRetry` names `POST`. | Flow | Token endpoint | Client credentials travel | | --- | --- | --- | @@ -139,7 +141,7 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | `client.search` | `Search` | 1 | `search` | | `client.shows` | `Shows` | 7 | `checkUsersSavedShows` · `getAShow` · `getAShowsEpisodes` · `getMultipleShows` · `getUsersSavedShows` · `removeShowsUser` · `saveShowsUser` | -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** A name that collides inside its namespace is promoted to `RequestParams` instead — no operation in this SDK takes that spelling at this version. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -147,7 +149,7 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: @@ -156,11 +158,11 @@ Locate the installed package before you rely on a lookup: node -e "console.log(require.resolve('spotify-web-api/package.json'))" ``` -Failing that it is at `node_modules/spotify-web-api/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/spotify-web-api/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/spotify-web-api/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -168,7 +170,7 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/spotify-web-api/`: @@ -178,13 +180,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -198,17 +202,23 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `SPOTIFY_WEB_API_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new SpotifyWebApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters and the OAuth 2 token cache live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) -2. **Authentication** — load **typescript-authentication** before you set credentials. The 2 schemes are `oauth20`, `oauth20ClientCredentials` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; the token is fetched lazily and cached on the client; a **failed token fetch** raises `AuthError`, which is not a `ResponseError` and **bypasses `.asApiResult()` entirely**; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new SpotifyWebApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters and the OAuth 2 token cache live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) +2. **Authentication** — load **typescript-authentication** before you set credentials. The 2 schemes are `oauth20`, `oauth20ClientCredentials` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; the token is fetched lazily and cached on the client; a **failed token fetch** raises `AuthError`, which is not an `ApiError` and **bypasses `.asApiResult()` entirely**; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **43 operations resolve to `undefined`**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `SpotifyWebApiError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `SpotifyWebApiError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `SPOTIFY_WEB_API_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK @@ -217,10 +227,12 @@ Beyond the usual signatures and model members, a contract sheet for the Spotify 1. **Which host each deployment talks to**, and where that is set. The members are `ServerEnvironment.Production`, `ServerEnvironment.Environment2`, defaulting to `ServerEnvironment.Production` when the field is unset. 2. **43 operations resolve to `undefined`** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 138 of 138 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 138 of 138 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/spotify/skills/typescript/typescript-integrate-spotify-web-api/SKILL.md b/plugins/spotify/skills/typescript/typescript-integrate-spotify-web-api/SKILL.md index 17db5cd17..c2f9b0dfc 100644 --- a/plugins/spotify/skills/typescript/typescript-integrate-spotify-web-api/SKILL.md +++ b/plugins/spotify/skills/typescript/typescript-integrate-spotify-web-api/SKILL.md @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/spotify-web-api/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('spotify-web-api/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('spotify-web-api/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. @@ -23,7 +23,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/spotify-web-api-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/spotify-web-api-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** @@ -40,11 +40,85 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** installing `spotify-web-api` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. @@ -63,8 +137,10 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from the **43 operations that resolve to `undefined`** (every operation whose success declares no body — the map's **Returns** bullet names them). `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `SpotifyWebApiError` family, told apart by `err.kind` — and an unknown `serverEnvironment` throws `ConfigurationError` out of the client constructor, before any call exists. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `SPOTIFY_WEB_API_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions @@ -74,7 +150,7 @@ A standalone Spotify Web API question with no code change: look it up in `typesc - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. - **Never write any Spotify Web API or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. - **Don't web-search Spotify Web API topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Spotify Web API docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. diff --git a/plugins/spotify/skills/typescript/typescript-models/SKILL.md b/plugins/spotify/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/spotify/skills/typescript/typescript-models/SKILL.md +++ b/plugins/spotify/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/spotify/skills/typescript/typescript-models/reference.md b/plugins/spotify/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/spotify/skills/typescript/typescript-models/reference.md +++ b/plugins/spotify/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/spotify/skills/typescript/typescript-testing/SKILL.md b/plugins/spotify/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/spotify/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/spotify/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/square/.claude-plugin/plugin.json b/plugins/square/.claude-plugin/plugin.json index 2bf4a6471..dc7a75338 100644 --- a/plugins/square/.claude-plugin/plugin.json +++ b/plugins/square/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "square", "displayName": "Square", - "version": "0.3.1", + "version": "0.4.0", "description": "Integrate and consume the APIMatic-generated Square SDK — bundled skills for C#/.NET, Python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/square/.cursor-plugin/plugin.json b/plugins/square/.cursor-plugin/plugin.json index 2bf4a6471..dc7a75338 100644 --- a/plugins/square/.cursor-plugin/plugin.json +++ b/plugins/square/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "square", "displayName": "Square", - "version": "0.3.1", + "version": "0.4.0", "description": "Integrate and consume the APIMatic-generated Square SDK — bundled skills for C#/.NET, Python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/square/plugin.json b/plugins/square/plugin.json index 83d95ea5d..dc7a75338 100644 --- a/plugins/square/plugin.json +++ b/plugins/square/plugin.json @@ -1,13 +1,15 @@ { "name": "square", - "displayName": "square", - "version": "0.3.1", - "description": "Integrate and consume the APIMatic-generated Square SDK — bundled skills for C#/.NET, python, TypeScript.", + "displayName": "Square", + "version": "0.4.0", + "description": "Integrate and consume the APIMatic-generated Square SDK — bundled skills for C#/.NET, Python, TypeScript.", "author": { "name": "APIMatic", "email": "developer@apimatic.io" }, "license": "MIT", + "homepage": "https://context.apimatic.io/", + "repository": "https://github.com/context-plugins/plugin-marketplace", "skills": [ "./skills/dotnet", "./skills/python", diff --git a/plugins/square/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/square/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..71fbb8632 100644 --- a/plugins/square/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/square/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,86 +1,36 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Square .NET SDK in C# — this API authenticates with an API key in a header and the OAuth 2.0 authorization-code grant, and this skill gives the credential each scheme takes, the SquareClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Square .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with an API key in a header and the OAuth 2.0 authorization-code grant. APIMatic surfaces each scheme as a **nullable credentials property** on `SquareClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) - -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). - -## Basic auth - -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` - -Sends `Authorization: Basic base64(username:password)`. - -## Bearer token - -Set the configured token property on the options class to your access-token string: - -```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; -``` - -Sends `Authorization: Bearer ACCESS_TOKEN`. - -## API key (header, query, or cookie) +| Scheme | Property on `SquareClientOptions` | Credential | +| --- | --- | --- | +| API key — `Authorization` header | `Oauth2ClientSecret` | `string` | +| OAuth 2.0 authorization code | `Oauth2` | `OAuth2AuthorizationCodeCredentials` | -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: +**That table is the entire authentication surface of this SDK.** `Square.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` +## API key (header) -## OAuth 2.0 — client credentials (machine-to-machine) +The key travels in the `Authorization` request header. The header name is fixed by the generated scheme — you supply only the value: ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; - -options.{OAuthProperty} = new OAuth2ClientCredentials -{ - ClientId = "...", - ClientSecret = "...", - Scope = "..." // optional -}; +options.Oauth2ClientSecret = "API_KEY"; ``` -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. - ## OAuth 2.0 — authorization code (3-legged, with PKCE) ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; +using Square.Core.Authentication.OAuth2.AuthorizationCode; -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials +options.Oauth2 = new OAuth2AuthorizationCodeCredentials { ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE + ClientSecret = "...", RedirectUri = "https://app.example.com/callback", Scope = "...", // optional State = "...", // optional CSRF token @@ -94,152 +44,92 @@ options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials }; ``` -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. +The SDK sends the user to `https://connect.squareup.com/oauth2/authorize`, exchanges the returned code for a token at `https://connect.squareup.com/oauth2/token`, and refreshes it when it expires; if the refresh fails, it invokes `PromptForAuthorizationCode` again to re-authorize. -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: +`PromptForAuthorizationCode` runs inside the call that needs a new token, under the scheme's fetch lock, so it must not make an SDK call that uses this scheme. It counts against the per-attempt `options.Retry.Timeout`, 100 s by default, and when the call is retryable (by default a `GET`, `HEAD`, `PUT` or `OPTIONS`) a timed-out attempt is retried and prompts again. A prompt that throws anything but `OperationCanceledException` fails the call with `AuthSchemeException`. `State` is sent as you set it and never checked, so your redirect handler must verify it. -| PKCE | token request built | no secret ⇒ | -| --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | +⚠ **`ClientSecret` is required here — PKCE does not make it optional.** This SDK builds the token request with `OAuth2AuthorizationCodeStrategy.ForBasicAuthRequest`, which sends the client id and secret as HTTP Basic credentials on the exchange. Omit it and the call fails with an `AuthSchemeException` wrapping an `InvalidOperationException`, at one of two very different moments: -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. +- **PKCE disabled** (`Pkce = null`) — throws *before* `PromptForAuthorizationCode` runs. +- **PKCE enabled** (the default `S256`) — throws *after* the prompt has already run. The user completes a full browser round-trip, and only then does the exchange fail. This is the expensive one. -## OAuth 2.0 — resource owner password +The public-client carve-out — a secret-less PKCE flow — belongs to the form-body token request, which this SDK does not use. Do not plan around it here. -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; +## Token caching & refresh -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` +- Tokens are cached in-memory, **per client instance**, and reused until **30s** before expiry. +- **This grant refreshes.** The authorization-code grant is wired to `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`, so an expired access token is exchanged for a new one using the refresh token. +- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: a `401` there is an interactive re-authorization, not a silent refresh. **Plan for that in a non-interactive host** — a background worker will block on a prompt nobody can answer. +- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of a fresh token. -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. +⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** `expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to "not expired" when it is absent — so the first token is cached for the life of the client and the only thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. ### Supplying your own token source -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. +The token strategy is a public extension point: `SquareClientOptions` exposes a strategy property beside each credentials property, and the generated client falls back to the built-in strategy only when you leave it null. The strategy is used only when the credentials property beside it is also set, and `GetToken` receives that object. + +| Grant | Strategy property on `SquareClientOptions` | Type | +| --- | --- | --- | +| Authorization code | `Oauth2TokenStrategy` | `IOAuth2RefreshableTokenStrategy` | ```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) +options.Oauth2 = new OAuth2AuthorizationCodeCredentials { /* its required members */ }; +options.Oauth2TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) + // Task TryRefreshToken(creds, refreshToken, ct) ``` -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. +Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test double. The per-client in-memory cache above still wraps whatever you return. -## Combined / multiple schemes +## Combining the schemes -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: +This SDK generates more than one credentials property, and its operations compose them. You configure the composition by setting the relevant properties on `SquareClientOptions` — the generated client wires the rest. -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). +## Operations that need no credentials -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. - -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +Some operations on this API are public: the generated endpoint uses `NoneAuthScheme` and sends no credential, whatever you set on `SquareClientOptions`. You still construct the same client — there is no separate unauthenticated client — and the credentials you configured are simply not applied to those calls. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `SquareClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("SquareClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.Oauth2ClientSecret)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "SquareClient:Oauth2ClientSecret is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"SquareClient:Oauth2ClientSecret is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +Check every value the schemes actually need. For this SDK that is: + +| Value to validate | Feeds `SquareClientOptions` property | +| --- | --- | +| `Oauth2ClientSecret` | `Oauth2ClientSecret` | +| `ClientId` | `Oauth2` | +| `ClientSecret` | `Oauth2` | +| `RedirectUri` | `Oauth2` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddSquareClient(options => ...)` callback when registering via DI. `SquareClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddSquareClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/square/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/square/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/square/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/square/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/square/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/square/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/square/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/square/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/square/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/square/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/square/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/square/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/square/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/square/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/square/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/square/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException
` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/square/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/square/skills/dotnet/dotnet-getting-started/SKILL.md index 84e482dcd..895ec6cf3 100644 --- a/plugins/square/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/square/skills/dotnet/dotnet-getting-started/SKILL.md @@ -11,7 +11,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity @@ -26,7 +26,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any | Auth | **OAuth 2.0** authorization code — set `Oauth2`
**API key** in the `Authorization` header — set `Oauth2ClientSecret` | | Environments | `ServerEnvironment.Production` *(default)* → `https://connect.squareup.com`
`ServerEnvironment.Sandbox` → `https://connect.squareupsandbox.com`
`ServerEnvironment.Custom` → `{custom_url}` | | Log environment variable | `SQUARECLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-square` makes that binding); the **Integration workflow** below says which governs which step. @@ -44,22 +44,24 @@ Models, enums, union types and the generated error types each sit in their own c ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Square, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/Square/Models/`, `src/Square/Errors/` and `src/Square/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/Square/Models/`, enums `src/Square/Models/Enums/`, typed errors `src/Square/Errors/`, request records `src/Square/Requests/`, or `src/Square/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: @@ -69,7 +71,7 @@ Keep lookups cheap — the rules that keep a session's context small: ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/square-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/square-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. **Clone once, up front**, into a fresh timestamped folder under `/square-sdk-src/`, and reuse that folder for the rest of your session: @@ -87,30 +89,31 @@ git clone --filter=blob:none --branch main https://github.com/context-plugins/sq # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/square-sdk-src/`, **never** in the project repo, and its path never goes into `square-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `square-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `SquareClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/Square/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/Square/Models/` (+ `src/Square/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/Square/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/Square/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/Square/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/Square/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/Square/Servers/`, `src/Square/SquareClient.cs`, `src/Square/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Square may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything @@ -126,7 +129,7 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/Square/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step @@ -134,9 +137,9 @@ You loaded every companion the sheet named before starting (per `dotnet-integrat 1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new SquareClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) 2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `SquareClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/square/skills/dotnet/dotnet-integrate-square/SKILL.md b/plugins/square/skills/dotnet/dotnet-integrate-square/SKILL.md index f1b1d1765..fa9e74267 100644 --- a/plugins/square/skills/dotnet/dotnet-integrate-square/SKILL.md +++ b/plugins/square/skills/dotnet/dotnet-integrate-square/SKILL.md @@ -11,7 +11,7 @@ You are the Square .NET SDK specialist for this task — one agent for every SDK ## Grounding rules -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `square-plan.md` — the plan must stay portable. +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `square-plan.md` never carries its path — the plan must stay portable. - **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. - **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. - **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. @@ -29,7 +29,7 @@ Your FIRST deliverable is `square-plan.md` at `/square-plan.m #### How to ground (map-first, one pass) 1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. 3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. 4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. 5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. @@ -38,7 +38,7 @@ Your FIRST deliverable is `square-plan.md` at `/square-plan.m These need no SDK knowledge and touch no project file, so do them before or alongside the map work: -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); - the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; - locating the SDK reference and its version in the project; @@ -54,10 +54,23 @@ Never use this prerequisite work as a "head start" on implementation: **creating #### The `square-plan.md` format (keep it tight — tables, not prose) 1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + 3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. 6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. 7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. @@ -67,12 +80,46 @@ Never use this prerequisite work as a "head start" on implementation: **creating | 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | | 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | | 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | | 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | | 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | | 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. @@ -95,14 +142,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. 3. After every change: `dotnet build`; fix non-SDK errors yourself. 4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `Square.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. ### Step 4 — Fixing SDK errors (map-first, in place) A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `Square.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/Square/Models/{Type}.cs`, `src/Square/Models/Enums/{Type}.cs`, `src/Square/Errors/{Type}.cs`, `src/Square/Requests/{Type}.cs`, or `src/Square/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. 3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". 4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). 5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. @@ -120,6 +168,6 @@ A standalone Square question with no code change: load `dotnet-getting-started`, - **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. - **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. - **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search Square for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `Square` is what the generated source declares, not what public docs describe. +- **Do not web-search Square for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `Square` is what the generated source declares, not what public docs describe. - **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. diff --git a/plugins/square/skills/dotnet/dotnet-models/SKILL.md b/plugins/square/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/square/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/square/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/square/skills/dotnet/dotnet-models/reference.md b/plugins/square/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/square/skills/dotnet/dotnet-models/reference.md +++ b/plugins/square/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/square/skills/dotnet/dotnet-testing/SKILL.md b/plugins/square/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/square/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/square/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/square/skills/python/python-authentication/SKILL.md b/plugins/square/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/square/skills/python/python-authentication/SKILL.md +++ b/plugins/square/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/square/skills/python/python-calling-endpoints/SKILL.md b/plugins/square/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/square/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/square/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/square/skills/python/python-client-initialization/SKILL.md b/plugins/square/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/square/skills/python/python-client-initialization/SKILL.md +++ b/plugins/square/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/square/skills/python/python-configuration-resilience/SKILL.md b/plugins/square/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/square/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/square/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/square/skills/python/python-error-handling/SKILL.md b/plugins/square/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/square/skills/python/python-error-handling/SKILL.md +++ b/plugins/square/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/square/skills/python/python-event-streams/SKILL.md b/plugins/square/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/square/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/square/skills/python/python-file-handling/SKILL.md b/plugins/square/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/square/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/square/skills/python/python-getting-started/SKILL.md b/plugins/square/skills/python/python-getting-started/SKILL.md index 0f4d11d05..a6d496ca9 100644 --- a/plugins/square/skills/python/python-getting-started/SKILL.md +++ b/plugins/square/skills/python/python-getting-started/SKILL.md @@ -25,12 +25,12 @@ Verified against `square/` and `pyproject.toml` of the generated package at vers | Version | `2.0` | | Sync client class | `SquareClient` (alias `Client`) | | Async client class | `AsyncSquareClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `environment` · `base_url` · `timeout` (default `30.0`) · `oauth2` · `oauth2_token_source` · `oauth2_client_secret`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Client construction | **keyword-only**: `environment` · `base_url` · `timeout` (default `30.0`) · `retry_options` · `oauth2` · `oauth2_token_source` · `oauth2_client_secret`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | **OAuth 2.0** authorization code — set `oauth2` · **API key** in the `Authorization` header — set `oauth2_client_secret` | | Environments | 3 environments selected by `environment` (default `"production"`), overridable with `base_url` | | Base-URL config | `ServerConfig` (`square/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | | Surface | 334 operations across 43 controllers · 1271 models · 0 unions · 203 enums · 0 per-operation error unions | @@ -39,13 +39,18 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash pip install "square @ git+https://github.com/context-plugins/square-python-sdk.git@main" +python -c "import square, pathlib; print(pathlib.Path(square.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules @@ -70,7 +75,7 @@ Everything else comes from its own subpackage, and the split matters because eac | --- | --- | | Domain models, their `…Dict` companions | `square.models` | | Enums (and their open `…OrStr` aliases) | `square.models.enums` | -| `ApiError` · `RawError` · `ApiResult` · `RequestOptions` · `AuthorizationCodeCredentials` · `HttpClient` · `SdkBaseModel` · `UNSET` · `Optional` | `square.core` | +| `ApiError` · `RawError` · `ApiResult` · `RequestOptions` · `AuthorizationCodeCredentials` · `HttpClient` · `SdkBaseModel` · `UNSET` · `Optional` · `FileInput` · `AsyncFileInput` · `NamedFile` · `BinaryContent` · `AsyncBinaryContent` · `BinaryReader` · `AsyncBinaryReader` | `square.core` | | Per-operation error *unions* | `square.errors` — *this SDK declares none* | `square.core` re-exports its whole public surface (a curated `__all__`), so import from `…core` rather than from the private modules beneath it (`…core.results`, `…core.exceptions`, `…core.auth.schemes`). @@ -179,15 +184,15 @@ The SDK map's controller table carries the same counts and links to a page per c The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `square/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `square/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `square/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/square-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. Every `Source` path on the map is relative to that SDK root, so `square/models/achdetails.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. @@ -207,7 +212,7 @@ Read the one module that owns the fact **inside the installed package**. Locate python -c "import square, pathlib; print(pathlib.Path(square.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/square` on Windows, `.venv/lib/python3.*/site-packages/square` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/square` on Windows, `.venv/lib/python3.*/site-packages/square` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -215,7 +220,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/sq | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -224,8 +229,9 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/sq | An enum's members and wire values | `models/enums/` | | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | +| What a file or binary parameter accepts, and the reader protocols behind it (the aliases are in the import table above; not in the map's Type sources) | `core/files.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | @@ -241,13 +247,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The two schemes are `oauth2=` and `oauth2_client_secret=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; the token is fetched lazily and cached by the client; and a **failed token fetch** raises something other than what a caller expects and **bypasses the non-raising response mode** entirely. Load secrets from the environment or a secret store, never hardcode.) -3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; and the two response modes — raising vs `ApiResult` — behave differently on failure.) +3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; a multipart body is **one flat parameter list** — every field, JSON part and file is its own parameter, and an optional part is omitted with `None` rather than by a second call shape; and the two response modes — raising vs `ApiResult` — behave differently on failure.) (*For the file positions:* load **python-file-handling** before you pass anything but a `Path` — a file position accepts far more than `bytes`, **never a `str`**, and nothing validates what you hand it.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`), so an unknown wire value passes through as a plain `str` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -255,11 +261,13 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 1. **Sync or async** — which client class, and the reminder that the two do not mix. Plus the `close()`/`aclose()` obligation and where the client is held. 2. **The keyword-only boundary** for each operation: what is positional (path params, sometimes the body) and what sits after `*`. Every keyword-only parameter has a real default, so there is no "must pass `None` explicitly" hazard — say so, so nobody writes defensive `None`s. -3. **Every operation returns a payload** — no operation in this SDK returns `None`, so the `with_raw_response` peer is needed only where the status code itself matters. -4. **Required vs `UNSET`** for every model member the task sets, and the fact that `Optional[T]` here is `T | UnsetType` — **not** `typing.Optional`, so `None` is not a legal value for it. -5. **The `ApiError.error` union** for each operation in scope — no operation in this SDK documents a typed error body, so `.error` is always `RawError`. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 5 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"` of the 3 declared. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +3. **The file positions** — `catalog.create_catalog_image` · `catalog.update_catalog_image` · `disputes.create_dispute_evidence_file` · `invoices.create_invoice_attachment`, each annotated `FileInput` on the sync client and `AsyncFileInput` on the async one, with `list[…]` for an array part. What those admit is wider than the annotation shows and **never a `str`**, and the arm you pick decides what stays resident, who closes your handle and what the file is called on the wire — so the sheet cites **python-file-handling** for the row rather than restating it. +4. **Every operation returns a payload** — no operation in this SDK returns `None`, so the `with_raw_response` peer is needed only where the status code itself matters. +5. **Required vs `UNSET`** for every model member the task sets, and the fact that `Optional[T]` here is `T | UnsetType` — **not** `typing.Optional`, so `None` is not a legal value for it. +6. **The `ApiError.error` union** for each operation in scope — no operation in this SDK documents a typed error body, so `.error` is always `RawError`. +7. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 5 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +8. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +9. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +10. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"` of the 3 declared. +11. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/square/skills/python/python-integrate-square/SKILL.md b/plugins/square/skills/python/python-integrate-square/SKILL.md index c2085dcf6..43e62693c 100644 --- a/plugins/square/skills/python/python-integrate-square/SKILL.md +++ b/plugins/square/skills/python/python-integrate-square/SKILL.md @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `python-getting-started` is your **lookup layer - **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import square, pathlib; print(pathlib.Path(square.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import square, pathlib; print(pathlib.Path(square.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) - **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. @@ -40,6 +40,21 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** creating or activating a virtual environment, installing `square` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. @@ -62,8 +77,9 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. 4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `square.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `square.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. ### Step 3 — Answering pure questions diff --git a/plugins/square/skills/python/python-models/SKILL.md b/plugins/square/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/square/skills/python/python-models/SKILL.md +++ b/plugins/square/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/square/skills/python/python-models/reference.md b/plugins/square/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/square/skills/python/python-models/reference.md +++ b/plugins/square/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/square/skills/python/python-testing/SKILL.md b/plugins/square/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/square/skills/python/python-testing/SKILL.md +++ b/plugins/square/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/square/skills/typescript/typescript-authentication/SKILL.md b/plugins/square/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/square/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/square/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/square/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/square/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/square/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/square/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/square/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/square/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/square/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/square/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/square/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/square/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/square/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/square/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/square/skills/typescript/typescript-error-handling/SKILL.md b/plugins/square/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/square/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/square/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/square/skills/typescript/typescript-getting-started/SKILL.md b/plugins/square/skills/typescript/typescript-getting-started/SKILL.md index f62444f26..d4e10c35f 100644 --- a/plugins/square/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/square/skills/typescript/typescript-getting-started/SKILL.md @@ -13,22 +13,24 @@ This is the **SDK-specific** entry point. For general patterns that apply to any ## SDK identity -Verified against `package.json` and `sdk-map.md` of the generated package at version `2.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. +Verified against `package.json` and `sdk-map.md` of the generated package at version `2.0.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. | Fact | Value | | --- | --- | | API | Square | | Package name (what you install, and what you import) | `square` — **not on npm**; built from source (see *Install*) | | Import specifier | `square` — the package root is the **only** entry; deep imports do not resolve | -| Version | `2.0` (API spec version `2.0`) | +| Source repository | https://github.com/context-plugins/square-typescript-sdk (branch `main` — the ref this map documents) | +| Version | `2.0.0` (API spec version `2.0`) | | Client class | `SquareClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new SquareClient(clientOptions: Partial = {})` — **every** field is optional, so `new SquareClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `oauth2` · `oauth2Strategy` · `oauth2ClientSecret`. `timeout` defaults to `60_000` ms | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new SquareClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new SquareClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `retry` · `fetch` · `oauth2` · `oauth2Strategy` · `oauth2ClientSecret`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **OAuth 2 authorization code** — set `ClientOptions.oauth2`
**API key (header)** — set `ClientOptions.oauth2ClientSecret` | | Environments | 3 environments (`ServerEnvironment.Production` *(default)*, `ServerEnvironment.Sandbox`, `ServerEnvironment.Custom`) × 1 server group | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`), defaulting to `https://connect.squareup.com` | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Base-URL config | `serverOptions.baseUrl` (`src/servers.ts`), defaulting to `https://connect.squareup.com` | +| Log environment variable | `SQUARE_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Surface | 334 operations · 43 resources · 1271 models · 203 open enums · 0 per-operation error subclasses | @@ -37,22 +39,22 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/square-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 1474 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 1474 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { SquareClient, ServerEnvironment, ResponseError, SquareError } from "square"; +import { SquareClient, ServerEnvironment, ApiError, SquareError } from "square"; import type { ClientOptions, AchDetails } from "square"; ``` @@ -72,16 +74,16 @@ Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, | Group | Environment | Base URL | Override at | | --- | --- | --- | --- | -| `default` | `production` *(default)* | `https://connect.squareup.com` | `serverOptions.default.production.baseUrl` | -| `default` | `sandbox` | `https://connect.squareupsandbox.com` | `serverOptions.default.sandbox.baseUrl` | -| `default` | `custom` | `{custom_url}` | `serverOptions.default.custom.baseUrl` | +| `default` | `production` *(default)* | `https://connect.squareup.com` | `serverOptions.baseUrl` | +| `default` | `sandbox` | `https://connect.squareupsandbox.com` | `serverOptions.baseUrl` | +| `default` | `custom` | `{custom_url}` | `serverOptions.baseUrl` | Consequences to state on every contract sheet that touches configuration: - Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. +- An environment value the SDK does not know throws `ConfigurationError` **from the constructor** — every server group is resolved once, as the client is built — so no operation method throws synchronously. ## Auth pattern (2 schemes) @@ -105,9 +107,9 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. -**OAuth 2 fetches and caches its own token.** The token request goes through **the same client** — same `timeout`, same `fetch` — sends a form-urlencoded body, and decodes the response against a schema rather than casting it. A token is cached until shortly before it expires; a response with no `expires_in` is treated as never expiring (RFC 6749 §5.1); concurrent callers share one in-flight fetch. **A refused token endpoint rejects with `AuthError`**, wrapping the underlying `ResponseError` as `cause` — so a bad secret never looks like the business call failing, and `AuthError` is *not* a `ResponseError`. +**OAuth 2 fetches and caches its own token.** The token request goes through **the same client** — same `fetch`, same `retry` policy — sends a form-urlencoded body, and decodes the response against a schema rather than casting it. A token is cached until shortly before it expires; a response with no `expires_in` is treated as never expiring (RFC 6749 §5.1); concurrent callers share one in-flight fetch. **A refused token endpoint rejects with `AuthError`**, wrapping the underlying `ApiError` as `cause` — so a bad secret never looks like the business call failing, and `AuthError` is *not* an `ApiError`. The token request is itself a `POST` with its own timeout, retried only when `httpMethodsToRetry` names `POST`. | Flow | Token endpoint | Client credentials travel | | --- | --- | --- | @@ -169,15 +171,15 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | `client.vendors` | `Vendors` | 7 | `bulkCreateVendors` · `bulkRetrieveVendors` · `bulkUpdateVendors` · `createVendor` · `retrieveVendor` · `searchVendors` · `updateVendor` | | `client.webhookSubscriptions` | `WebhookSubscriptions` | 8 | `createWebhookSubscription` · `deleteWebhookSubscription` · `listWebhookEventTypes` · `listWebhookSubscriptions` · `retrieveWebhookSubscription` · `testWebhookSubscription` · `updateWebhookSubscription` · `updateWebhookSubscriptionSignatureKey` | -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. -⚠ **The request type name is not uniformly `Request`.** 163 operations take `RequestParams` instead: `obtainToken`, `revokeToken`, `v1UpdateOrder`, `registerDomain`, `createBankAccount`, `bulkRetrieveBookings`, `bulkRetrieveTeamMemberBookingProfiles`, `cancelBooking`, `createBooking`, `searchAvailability`, `updateBooking`, `bulkDeleteBookingCustomAttributes`, `bulkUpsertBookingCustomAttributes`, `createBookingCustomAttributeDefinition`, `updateBookingCustomAttributeDefinition`, `upsertBookingCustomAttribute`, `createCard`, `batchDeleteCatalogObjects`, `batchRetrieveCatalogObjects`, `batchUpsertCatalogObjects`, `searchCatalogItems`, `searchCatalogObjects`, `updateCatalogImage`, `updateItemModifierLists`, `updateItemTaxes`, `upsertCatalogObject`, `bulkRetrieveChannels`, `bulkCreateCustomers`, `bulkDeleteCustomers`, `bulkRetrieveCustomers`, `bulkUpdateCustomers`, `createCustomer`, `createCustomerCard`, `searchCustomers`, `updateCustomer`, `bulkUpsertCustomerCustomAttributes`, `createCustomerCustomAttributeDefinition`, `updateCustomerCustomAttributeDefinition`, `upsertCustomerCustomAttribute`, `createCustomerGroup`, `updateCustomerGroup`, `createDeviceCode`, `createDisputeEvidenceFile`, `createDisputeEvidenceText`, `searchEvents`, `createGiftCard`, `linkCustomerToGiftCard`, `retrieveGiftCardFromGan`, `retrieveGiftCardFromNonce`, `unlinkCustomerFromGiftCard`, `createGiftCardActivity`, `batchChangeInventory`, `batchRetrieveInventoryChanges`, `batchRetrieveInventoryCounts`, `createInventoryAdjustmentReason`, `deleteInventoryAdjustmentReason`, `restoreInventoryAdjustmentReason`, `retrieveInventoryAdjustmentReason`, `updateInventoryAdjustment`, `updateInventoryAdjustmentReason`, `cancelInvoice`, `createInvoice`, `createInvoiceAttachment`, `publishInvoice`, `searchInvoices`, `updateInvoice`, `bulkPublishScheduledShifts`, `createBreakType`, `createScheduledShift`, `createShift`, `createTimecard`, `publishScheduledShift`, `searchScheduledShifts`, `searchShifts`, `searchTimecards`, `updateBreakType`, `updateScheduledShift`, `updateShift`, `updateTimecard`, `updateWorkweekConfig`, `createLocation`, `updateLocation`, `bulkDeleteLocationCustomAttributes`, `bulkUpsertLocationCustomAttributes`, `createLocationCustomAttributeDefinition`, `updateLocationCustomAttributeDefinition`, `upsertLocationCustomAttribute`, `createCheckout`, `createPaymentLink`, `updateLocationSettings`, `updateMerchantSettings`, `updatePaymentLink`, `accumulateLoyaltyPoints`, `adjustLoyaltyPoints`, `calculateLoyaltyPoints`, `createLoyaltyAccount`, `createLoyaltyPromotion`, `createLoyaltyReward`, `redeemLoyaltyReward`, `searchLoyaltyAccounts`, `searchLoyaltyEvents`, `searchLoyaltyRewards`, `bulkDeleteMerchantCustomAttributes`, `bulkUpsertMerchantCustomAttributes`, `createMerchantCustomAttributeDefinition`, `updateMerchantCustomAttributeDefinition`, `upsertMerchantCustomAttribute`, `batchRetrieveOrders`, `calculateOrder`, `cloneOrder`, `createOrder`, `payOrder`, `searchOrders`, `updateOrder`, `bulkDeleteOrderCustomAttributes`, `bulkUpsertOrderCustomAttributes`, `createOrderCustomAttributeDefinition`, `updateOrderCustomAttributeDefinition`, `upsertOrderCustomAttribute`, `cancelPaymentByIdempotencyKey`, `completePayment`, `createPayment`, `updatePayment`, `refundPayment`, `upsertSnippet`, `bulkSwapPlan`, `changeBillingAnchorDate`, `createSubscription`, `pauseSubscription`, `resumeSubscription`, `searchSubscriptions`, `swapPlan`, `updateSubscription`, `bulkCreateTeamMembers`, `bulkUpdateTeamMembers`, `createJob`, `createTeamMember`, `searchTeamMembers`, `updateJob`, `updateTeamMember`, `updateWageSetting`, `createTerminalAction`, `createTerminalCheckout`, `createTerminalRefund`, `searchTerminalActions`, `searchTerminalCheckouts`, `searchTerminalRefunds`, `cancelTransferOrder`, `createTransferOrder`, `receiveTransferOrder`, `searchTransferOrders`, `startTransferOrder`, `updateTransferOrder`, `bulkCreateVendors`, `bulkRetrieveVendors`, `bulkUpdateVendors`, `createVendor`, `searchVendors`, `updateVendor`, `createWebhookSubscription`, `testWebhookSubscription`, `updateWebhookSubscription`, `updateWebhookSubscriptionSignatureKey`. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. +⚠ **The request type name is not uniformly `Request`.** 164 operations take `RequestParams` instead: `obtainToken`, `revokeToken`, `v1UpdateOrder`, `registerDomain`, `createBankAccount`, `bulkRetrieveBookings`, `bulkRetrieveTeamMemberBookingProfiles`, `cancelBooking`, `createBooking`, `searchAvailability`, `updateBooking`, `bulkDeleteBookingCustomAttributes`, `bulkUpsertBookingCustomAttributes`, `createBookingCustomAttributeDefinition`, `updateBookingCustomAttributeDefinition`, `upsertBookingCustomAttribute`, `createCard`, `batchDeleteCatalogObjects`, `batchRetrieveCatalogObjects`, `batchUpsertCatalogObjects`, `createCatalogImage`, `searchCatalogItems`, `searchCatalogObjects`, `updateCatalogImage`, `updateItemModifierLists`, `updateItemTaxes`, `upsertCatalogObject`, `bulkRetrieveChannels`, `bulkCreateCustomers`, `bulkDeleteCustomers`, `bulkRetrieveCustomers`, `bulkUpdateCustomers`, `createCustomer`, `createCustomerCard`, `searchCustomers`, `updateCustomer`, `bulkUpsertCustomerCustomAttributes`, `createCustomerCustomAttributeDefinition`, `updateCustomerCustomAttributeDefinition`, `upsertCustomerCustomAttribute`, `createCustomerGroup`, `updateCustomerGroup`, `createDeviceCode`, `createDisputeEvidenceFile`, `createDisputeEvidenceText`, `searchEvents`, `createGiftCard`, `linkCustomerToGiftCard`, `retrieveGiftCardFromGan`, `retrieveGiftCardFromNonce`, `unlinkCustomerFromGiftCard`, `createGiftCardActivity`, `batchChangeInventory`, `batchRetrieveInventoryChanges`, `batchRetrieveInventoryCounts`, `createInventoryAdjustmentReason`, `deleteInventoryAdjustmentReason`, `restoreInventoryAdjustmentReason`, `retrieveInventoryAdjustmentReason`, `updateInventoryAdjustment`, `updateInventoryAdjustmentReason`, `cancelInvoice`, `createInvoice`, `createInvoiceAttachment`, `publishInvoice`, `searchInvoices`, `updateInvoice`, `bulkPublishScheduledShifts`, `createBreakType`, `createScheduledShift`, `createShift`, `createTimecard`, `publishScheduledShift`, `searchScheduledShifts`, `searchShifts`, `searchTimecards`, `updateBreakType`, `updateScheduledShift`, `updateShift`, `updateTimecard`, `updateWorkweekConfig`, `createLocation`, `updateLocation`, `bulkDeleteLocationCustomAttributes`, `bulkUpsertLocationCustomAttributes`, `createLocationCustomAttributeDefinition`, `updateLocationCustomAttributeDefinition`, `upsertLocationCustomAttribute`, `createCheckout`, `createPaymentLink`, `updateLocationSettings`, `updateMerchantSettings`, `updatePaymentLink`, `accumulateLoyaltyPoints`, `adjustLoyaltyPoints`, `calculateLoyaltyPoints`, `createLoyaltyAccount`, `createLoyaltyPromotion`, `createLoyaltyReward`, `redeemLoyaltyReward`, `searchLoyaltyAccounts`, `searchLoyaltyEvents`, `searchLoyaltyRewards`, `bulkDeleteMerchantCustomAttributes`, `bulkUpsertMerchantCustomAttributes`, `createMerchantCustomAttributeDefinition`, `updateMerchantCustomAttributeDefinition`, `upsertMerchantCustomAttribute`, `batchRetrieveOrders`, `calculateOrder`, `cloneOrder`, `createOrder`, `payOrder`, `searchOrders`, `updateOrder`, `bulkDeleteOrderCustomAttributes`, `bulkUpsertOrderCustomAttributes`, `createOrderCustomAttributeDefinition`, `updateOrderCustomAttributeDefinition`, `upsertOrderCustomAttribute`, `cancelPaymentByIdempotencyKey`, `completePayment`, `createPayment`, `updatePayment`, `refundPayment`, `upsertSnippet`, `bulkSwapPlan`, `changeBillingAnchorDate`, `createSubscription`, `pauseSubscription`, `resumeSubscription`, `searchSubscriptions`, `swapPlan`, `updateSubscription`, `bulkCreateTeamMembers`, `bulkUpdateTeamMembers`, `createJob`, `createTeamMember`, `searchTeamMembers`, `updateJob`, `updateTeamMember`, `updateWageSetting`, `createTerminalAction`, `createTerminalCheckout`, `createTerminalRefund`, `searchTerminalActions`, `searchTerminalCheckouts`, `searchTerminalRefunds`, `cancelTransferOrder`, `createTransferOrder`, `receiveTransferOrder`, `searchTransferOrders`, `startTransferOrder`, `updateTransferOrder`, `bulkCreateVendors`, `bulkRetrieveVendors`, `bulkUpdateVendors`, `createVendor`, `searchVendors`, `updateVendor`, `createWebhookSubscription`, `testWebhookSubscription`, `updateWebhookSubscription`, `updateWebhookSubscriptionSignatureKey`. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. ## SDK map — look up first, open the file second The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: @@ -186,11 +188,11 @@ Locate the installed package before you rely on a lookup: node -e "console.log(require.resolve('square/package.json'))" ``` -Failing that it is at `node_modules/square/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/square/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/square/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -198,7 +200,7 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/square/`: @@ -208,13 +210,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -228,17 +232,23 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `SQUARE_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new SquareClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters and the OAuth 2 token cache live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) -2. **Authentication** — load **typescript-authentication** before you set credentials. The 2 schemes are `oauth2`, `oauth2ClientSecret` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; the token is fetched lazily and cached on the client; a **failed token fetch** raises `AuthError`, which is not a `ResponseError` and **bypasses `.asApiResult()` entirely**; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new SquareClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters and the OAuth 2 token cache live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) +2. **Authentication** — load **typescript-authentication** before you set credentials. The 2 schemes are `oauth2`, `oauth2ClientSecret` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; the token is fetched lazily and cached on the client; a **failed token fetch** raises `AuthError`, which is not an `ApiError` and **bypasses `.asApiResult()` entirely**; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **no operation resolves to `undefined` — every one of them carries a body**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `SquareError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `SquareError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `SQUARE_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK @@ -246,12 +256,14 @@ Beyond the usual signatures and model members, a contract sheet for the Square T 1. **Which host each deployment talks to**, and where that is set. The members are `ServerEnvironment.Production`, `ServerEnvironment.Sandbox`, `ServerEnvironment.Custom`, defaulting to `ServerEnvironment.Production` when the field is unset. 2. **no operation resolves to `undefined` — every one of them carries a body** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. -3. **The exact request type name per operation**, taken from the **Signature** bullet — 163 operations take `RequestParams`, not `Request`. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +3. **The exact request type name per operation**, taken from the **Signature** bullet — 164 operations take `RequestParams`, not `Request`. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. No operation declares a typed error body, so all 334 reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. **That `Error`, `Event` imported from this package are model types, not the globals of those names** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `SquareError` for the same reason. -10. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. No operation declares a typed error body, so all 334 reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. **That `Error`, `Event` imported from this package are model types, not the globals of those names** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `SquareError` for the same reason. +12. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/square/skills/typescript/typescript-integrate-square/SKILL.md b/plugins/square/skills/typescript/typescript-integrate-square/SKILL.md index 23461ad88..d1e7f7d17 100644 --- a/plugins/square/skills/typescript/typescript-integrate-square/SKILL.md +++ b/plugins/square/skills/typescript/typescript-integrate-square/SKILL.md @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/square/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('square/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('square/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. @@ -23,7 +23,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/square-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/square-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** @@ -40,11 +40,85 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** installing `square` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. @@ -63,8 +137,10 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from an operation whose success carries no body. `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `SquareError` family, told apart by `err.kind` — and an unknown `serverEnvironment` throws `ConfigurationError` out of the client constructor, before any call exists. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `SQUARE_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions @@ -74,7 +150,7 @@ A standalone Square question with no code change: look it up in `typescript-gett - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. - **Never write any Square or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. - **Don't web-search Square topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Square docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. diff --git a/plugins/square/skills/typescript/typescript-models/SKILL.md b/plugins/square/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/square/skills/typescript/typescript-models/SKILL.md +++ b/plugins/square/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/square/skills/typescript/typescript-models/reference.md b/plugins/square/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/square/skills/typescript/typescript-models/reference.md +++ b/plugins/square/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/square/skills/typescript/typescript-testing/SKILL.md b/plugins/square/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/square/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/square/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/tesla/.claude-plugin/plugin.json b/plugins/tesla/.claude-plugin/plugin.json index e6aa065ab..f40d1b542 100644 --- a/plugins/tesla/.claude-plugin/plugin.json +++ b/plugins/tesla/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "tesla", "displayName": "Tesla Fleet Management API", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Tesla Fleet Management API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/tesla/.cursor-plugin/plugin.json b/plugins/tesla/.cursor-plugin/plugin.json index e6aa065ab..f40d1b542 100644 --- a/plugins/tesla/.cursor-plugin/plugin.json +++ b/plugins/tesla/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "tesla", "displayName": "Tesla Fleet Management API", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Tesla Fleet Management API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/tesla/plugin.json b/plugins/tesla/plugin.json index e6aa065ab..f40d1b542 100644 --- a/plugins/tesla/plugin.json +++ b/plugins/tesla/plugin.json @@ -1,7 +1,7 @@ { "name": "tesla", "displayName": "Tesla Fleet Management API", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Tesla Fleet Management API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/tesla/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/tesla/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..784b1cc5a 100644 --- a/plugins/tesla/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/tesla/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,67 +1,36 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Tesla Fleet Management API .NET SDK in C# — this API authenticates with a Bearer token, OAuth 2.0 client credentials and the OAuth 2.0 authorization-code grant, and this skill gives the credential each scheme takes, the TeslaFleetManagementApiClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Tesla Fleet Management API .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with a Bearer token, OAuth 2.0 client credentials and the OAuth 2.0 authorization-code grant. APIMatic surfaces each scheme as a **nullable credentials property** on `TeslaFleetManagementApiClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) - -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). - -## Basic auth - -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` +| Scheme | Property on `TeslaFleetManagementApiClientOptions` | Credential | +| --- | --- | --- | +| Bearer token | `BearerAuth` | `string` | +| OAuth 2.0 client credentials | `ThirdpartytokenClientCredentials` | `OAuth2ClientCredentials` | +| OAuth 2.0 authorization code | `ThirdpartytokenAuthorizationCode` | `OAuth2AuthorizationCodeCredentials` | -Sends `Authorization: Basic base64(username:password)`. +**That table is the entire authentication surface of this SDK.** `TeslaFleetManagementApi.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. ## Bearer token -Set the configured token property on the options class to your access-token string: +`options.BearerAuth` is a plain `string` — assign the access token itself, with no `Bearer` prefix, which the SDK adds: ```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; +options.BearerAuth = "ACCESS_TOKEN"; ``` Sends `Authorization: Bearer ACCESS_TOKEN`. -## API key (header, query, or cookie) - -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: - -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` - ## OAuth 2.0 — client credentials (machine-to-machine) ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; +using TeslaFleetManagementApi.Core.Authentication.OAuth2.ClientCredentials; -options.{OAuthProperty} = new OAuth2ClientCredentials +options.ThirdpartytokenClientCredentials = new OAuth2ClientCredentials { ClientId = "...", ClientSecret = "...", @@ -69,18 +38,17 @@ options.{OAuthProperty} = new OAuth2ClientCredentials }; ``` -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. +The SDK fetches the token from `https://fleet-api.prd.na.vn.cloud.tesla.com/token` and caches it, acquiring a fresh one when it expires; on a `401` it invalidates the cached token and re-acquires. The token request carries the client id and secret in the form body. ## OAuth 2.0 — authorization code (3-legged, with PKCE) ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; +using TeslaFleetManagementApi.Core.Authentication.OAuth2.AuthorizationCode; -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials +options.ThirdpartytokenAuthorizationCode = new OAuth2AuthorizationCodeCredentials { ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE + ClientSecret = "...", RedirectUri = "https://app.example.com/callback", Scope = "...", // optional State = "...", // optional CSRF token @@ -94,152 +62,87 @@ options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials }; ``` -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. +The SDK sends the user to `https://fleet-api.prd.na.vn.cloud.tesla.com/authorize`, exchanges the returned code for a token at `https://fleet-api.prd.na.vn.cloud.tesla.com/token`, and refreshes it when it expires; if the refresh fails, it invokes `PromptForAuthorizationCode` again to re-authorize. -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: +`PromptForAuthorizationCode` runs inside the call that needs a new token, under the scheme's fetch lock, so it must not make an SDK call that uses this scheme. It counts against the per-attempt `options.Retry.Timeout`, 100 s by default, and when the call is retryable (by default a `GET`, `HEAD`, `PUT` or `OPTIONS`) a timed-out attempt is retried and prompts again. A prompt that throws anything but `OperationCanceledException` fails the call with `AuthSchemeException`. `State` is sent as you set it and never checked, so your redirect handler must verify it. -| PKCE | token request built | no secret ⇒ | -| --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | - -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. +`ClientSecret` is **optional while PKCE is enabled** (the default `S256`): this SDK builds the token request with `OAuth2AuthorizationCodeStrategy.ForFormBodyRequest`, the public-client case. Set `Pkce = null` and the secret becomes required — the call fails with an `AuthSchemeException` wrapping `InvalidOperationException("ClientSecret is required when PKCE is disabled.")`, thrown *before* `PromptForAuthorizationCode` runs. -## OAuth 2.0 — resource owner password - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; +## Token caching & refresh -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` +- Tokens are cached in-memory, **per client instance**, and reused until **30s** before expiry. +- **Only the authorization-code grant refreshes.** It is the one grant wired to `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. The other grants above re-run in full when the token expires — and a `refresh_token` in their token response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do not infer refresh behaviour from what the provider returns. +- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: a `401` there is an interactive re-authorization, not a silent refresh. **Plan for that in a non-interactive host** — a background worker will block on a prompt nobody can answer. +- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of a fresh token. -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. +⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** `expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to "not expired" when it is absent — so the first token is cached for the life of the client and the only thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. ### Supplying your own token source -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. +The token strategy is a public extension point: `TeslaFleetManagementApiClientOptions` exposes a strategy property beside each credentials property, and the generated client falls back to the built-in strategy only when you leave it null. The strategy is used only when the credentials property beside it is also set, and `GetToken` receives that object. + +| Grant | Strategy property on `TeslaFleetManagementApiClientOptions` | Type | +| --- | --- | --- | +| Client credentials | `ThirdpartytokenClientCredentialsTokenStrategy` | `IOAuth2TokenStrategy` | +| Authorization code | `ThirdpartytokenAuthorizationCodeTokenStrategy` | `IOAuth2RefreshableTokenStrategy` | ```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) +options.ThirdpartytokenClientCredentials = new OAuth2ClientCredentials { /* its required members */ }; +options.ThirdpartytokenClientCredentialsTokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) ``` -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: +Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test double. The per-client in-memory cache above still wraps whatever you return. -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). +## Combining the schemes -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. +This SDK generates more than one credentials property, and its operations compose them. You configure the composition by setting the relevant properties on `TeslaFleetManagementApiClientOptions` — the generated client wires the rest. -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +**OR (`AuthSchemeAny`) — alternatives, tried in order.** Schemes with **no credentials configured are skipped, not tried**; the first configured scheme that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails. Configure exactly the one you intend to use — an extra credential set "just in case" changes which scheme authenticates the call. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `TeslaFleetManagementApiClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("TeslaFleetManagementApiClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.BearerAuth)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "TeslaFleetManagementApiClient:BearerAuth is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"TeslaFleetManagementApiClient:BearerAuth is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +Check every value **the scheme you chose** needs. The rows below are not one checklist: the schemes above are alternatives, so a row belonging to a scheme you are not using needs no value at all, and demanding one at startup blocks a boot that would have worked. Validate the rows whose property you set, and no others: + +| Value to validate | Feeds `TeslaFleetManagementApiClientOptions` property | +| --- | --- | +| `BearerAuth` | `BearerAuth` | +| `ClientId` | `ThirdpartytokenClientCredentials` | +| `ClientSecret` | `ThirdpartytokenClientCredentials` | +| `ClientId` | `ThirdpartytokenAuthorizationCode` | +| `ClientSecret` | `ThirdpartytokenAuthorizationCode` | +| `RedirectUri` | `ThirdpartytokenAuthorizationCode` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddTeslaFleetManagementApiClient(options => ...)` callback when registering via DI. `TeslaFleetManagementApiClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddTeslaFleetManagementApiClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/tesla/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/tesla/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/tesla/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/tesla/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/tesla/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/tesla/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/tesla/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/tesla/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/tesla/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/tesla/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/tesla/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/tesla/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/tesla/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/tesla/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/tesla/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/tesla/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException
` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/tesla/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/tesla/skills/dotnet/dotnet-getting-started/SKILL.md index 8f3e89157..432c0610f 100644 --- a/plugins/tesla/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/tesla/skills/dotnet/dotnet-getting-started/SKILL.md @@ -1,17 +1,17 @@ --- name: "dotnet-getting-started" -description: "Tesla Fleet Management API .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-tesla is the workflow that uses it." +description: "Tesla Fleet Management API .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-tesla-fleet-management-api is the workflow that uses it." --- # Getting started with the Tesla Fleet Management API .NET SDK -> **Who this skill is for.** This is the **map layer**: load it to ground every Tesla Fleet Management API SDK fact. `dotnet-integrate-tesla` is the workflow that drives it — you collect the contracts for the work in scope into `tesla-fleet-management-api-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. +> **Who this skill is for.** This is the **map layer**: load it to ground every Tesla Fleet Management API SDK fact. `dotnet-integrate-tesla-fleet-management-api` is the workflow that drives it — you collect the contracts for the work in scope into `tesla-fleet-management-api-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated .NET SDK (auth, calling endpoints, models, error handling, retries, testing), see the companion API-agnostic skills: `dotnet-client-initialization`, `dotnet-authentication`, `dotnet-calling-endpoints`, `dotnet-models`, `dotnet-error-handling`, `dotnet-configuration-resilience`, `dotnet-testing`. **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity @@ -26,9 +26,9 @@ This is the **SDK-specific** entry point. For general patterns that apply to any | Auth | **Bearer** token — set `BearerAuth`
**OAuth 2.0** authorization code — set `ThirdpartytokenAuthorizationCode`
**OAuth 2.0** client credentials — set `ThirdpartytokenClientCredentials` | | Environments | `ServerEnvironment.Production` *(default)* → `https://fleet-api.prd.na.vn.cloud.tesla.com`
`ServerEnvironment.Environment2` → `https://auth.tesla.com/oauth2/v3` | | Log environment variable | `TESLAFLEETMANAGEMENTAPICLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | -The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-tesla` makes that binding); the **Integration workflow** below says which governs which step. +The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-tesla-fleet-management-api` makes that binding); the **Integration workflow** below says which governs which step. ## Namespaces (using-directives) @@ -44,22 +44,24 @@ Models, enums, union types and the generated error types each sit in their own c ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Tesla Fleet Management API, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/TeslaFleetManagementApi/Models/`, `src/TeslaFleetManagementApi/Errors/` and `src/TeslaFleetManagementApi/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/TeslaFleetManagementApi/Models/`, enums `src/TeslaFleetManagementApi/Models/Enums/`, typed errors `src/TeslaFleetManagementApi/Errors/`, request records `src/TeslaFleetManagementApi/Requests/`, or `src/TeslaFleetManagementApi/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: @@ -69,7 +71,7 @@ Keep lookups cheap — the rules that keep a session's context small: ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/tesla-fleet-management-api-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/tesla-fleet-management-api-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. **Clone once, up front**, into a fresh timestamped folder under `/tesla-fleet-management-api-sdk-src/`, and reuse that folder for the rest of your session: @@ -87,30 +89,31 @@ git clone --filter=blob:none --branch main https://github.com/context-plugins/te # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/tesla-fleet-management-api-sdk-src/`, **never** in the project repo, and its path never goes into `tesla-fleet-management-api-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `tesla-fleet-management-api-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `TeslaFleetManagementApiClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/TeslaFleetManagementApi/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/TeslaFleetManagementApi/Models/` (+ `src/TeslaFleetManagementApi/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/TeslaFleetManagementApi/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/TeslaFleetManagementApi/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/TeslaFleetManagementApi/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/TeslaFleetManagementApi/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/TeslaFleetManagementApi/Servers/`, `src/TeslaFleetManagementApi/TeslaFleetManagementApiClient.cs`, `src/TeslaFleetManagementApi/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Tesla Fleet Management API may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything @@ -126,17 +129,17 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/TeslaFleetManagementApi/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step -You loaded every companion the sheet named before starting (per `dotnet-integrate-tesla`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: +You loaded every companion the sheet named before starting (per `dotnet-integrate-tesla-fleet-management-api`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: 1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new TeslaFleetManagementApiClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) 2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `TeslaFleetManagementApiClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/tesla/skills/dotnet/dotnet-integrate-tesla/SKILL.md b/plugins/tesla/skills/dotnet/dotnet-integrate-tesla-fleet-management-api/SKILL.md similarity index 65% rename from plugins/tesla/skills/dotnet/dotnet-integrate-tesla/SKILL.md rename to plugins/tesla/skills/dotnet/dotnet-integrate-tesla-fleet-management-api/SKILL.md index 30afb7f86..1e828a201 100644 --- a/plugins/tesla/skills/dotnet/dotnet-integrate-tesla/SKILL.md +++ b/plugins/tesla/skills/dotnet/dotnet-integrate-tesla-fleet-management-api/SKILL.md @@ -1,125 +1,173 @@ ---- -name: "dotnet-integrate-tesla" -description: "MANDATORY FIRST STEP for Tesla Fleet Management API .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Tesla Fleet Management API in C# — Charging, Energy, Partner, User, VehicleCommands, Vehicles — or when a Tesla Fleet Management API .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file tesla-fleet-management-api-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." ---- - -# Tesla Fleet Management API .NET SDK — Workflow (map + skills) - -You are the Tesla Fleet Management API .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. - -**Scope guard:** the APIMatic-generated Tesla Fleet Management API **.NET SDK** (root namespace `TeslaFleetManagementApi`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. - -## Grounding rules - -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `tesla-fleet-management-api-plan.md` — the plan must stay portable. -- **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. -- **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. -- **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. -- **The sheet records Tesla Fleet Management API's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. -- **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. - -## Workflow - -**If the user opens with a reported SDK error or unexpected Tesla Fleet Management API behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: - -### Step 1 — Plan first: write `tesla-fleet-management-api-plan.md` (always, for any implementation work) - -Your FIRST deliverable is `tesla-fleet-management-api-plan.md` at `/tesla-fleet-management-api-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. - -#### How to ground (map-first, one pass) - -1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. -3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. -4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. -5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. - -#### Prerequisites you can run alongside - -These need no SDK knowledge and touch no project file, so do them before or alongside the map work: - -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); -- the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; -- locating the SDK reference and its version in the project; -- credentials/environment verification (per the task's secret-handling rules); -- setting up your task tracking. - -Never use this prerequisite work as a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. - -#### HARD GATE - -**No project-file creation or edits until `tesla-fleet-management-api-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. - -#### The `tesla-fleet-management-api-plan.md` format (keep it tight — tables, not prose) - -1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. -3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. -6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. -7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. - -| # | Concern | The decision the plan must record | -| --- | --- | --- | -| 1 | **Credential fail-fast** | Where credentials are bound, and that the host refuses to start when one is missing or blank — rather than discovering it as a 401 on the first call in production. A multi-part credential needs **every** part checked, because a blank part is not a missing one. | -| 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | -| 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | -| 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | -| 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | -| 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | -| 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | - -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. - -Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. - -### Step 2 — Required reading (do this before you write any code) - -The contract sheet ends with a **REQUIRED READING** block, and its rows carry inline `MUST load ` pointers. **Load every `dotnet-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. If the sheet names none, load `dotnet-error-handling` anyway — every integration writes an error boundary. - -These are API-agnostic usage skills; loading them is not the same as reading the map. Contract *facts* still come only from the sheet or a map lookup. - -Before implementing, check the plan's **Assumptions & Blockers** section: - -- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `tesla-fleet-management-api-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. -- Minor assumptions only → proceed. - -Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. - -### Step 3 — Implement from the contract sheet - -1. Read `tesla-fleet-management-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. -2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. -3. After every change: `dotnet build`; fix non-SDK errors yourself. -4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `TeslaFleetManagementApi.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. - -### Step 4 — Fixing SDK errors (map-first, in place) - -A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `TeslaFleetManagementApi.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. - -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. -3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". -4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). -5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. -6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. - -If a fix corrects a row in `tesla-fleet-management-api-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. - -### Step 5 — Pure questions - -A standalone Tesla Fleet Management API question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. - -## Anti-patterns — never do these - -- **Never write a Tesla Fleet Management API/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** -- **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. -- **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. -- **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search Tesla Fleet Management API for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `TeslaFleetManagementApi` is what the generated source declares, not what public docs describe. -- **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. - +--- +name: "dotnet-integrate-tesla-fleet-management-api" +description: "MANDATORY FIRST STEP for Tesla Fleet Management API .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Tesla Fleet Management API in C# — Charging, Energy, Partner, User, VehicleCommands, Vehicles — or when a Tesla Fleet Management API .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file tesla-fleet-management-api-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." +--- + +# Tesla Fleet Management API .NET SDK — Workflow (map + skills) + +You are the Tesla Fleet Management API .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. + +**Scope guard:** the APIMatic-generated Tesla Fleet Management API **.NET SDK** (root namespace `TeslaFleetManagementApi`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. + +## Grounding rules + +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `tesla-fleet-management-api-plan.md` never carries its path — the plan must stay portable. +- **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. +- **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. +- **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. +- **The sheet records Tesla Fleet Management API's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. +- **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. + +## Workflow + +**If the user opens with a reported SDK error or unexpected Tesla Fleet Management API behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: + +### Step 1 — Plan first: write `tesla-fleet-management-api-plan.md` (always, for any implementation work) + +Your FIRST deliverable is `tesla-fleet-management-api-plan.md` at `/tesla-fleet-management-api-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. + +#### How to ground (map-first, one pass) + +1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. +3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. +4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. +5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. + +#### Prerequisites you can run alongside + +These need no SDK knowledge and touch no project file, so do them before or alongside the map work: + +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; +- `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; +- locating the SDK reference and its version in the project; +- credentials/environment verification (per the task's secret-handling rules); +- setting up your task tracking. + +Never use this prerequisite work as a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. + +#### HARD GATE + +**No project-file creation or edits until `tesla-fleet-management-api-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. + +#### The `tesla-fleet-management-api-plan.md` format (keep it tight — tables, not prose) + +1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + +3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. +7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. + +| # | Concern | The decision the plan must record | +| --- | --- | --- | +| 1 | **Credential fail-fast** | Where credentials are bound, and that the host refuses to start when one is missing or blank — rather than discovering it as a 401 on the first call in production. A multi-part credential needs **every** part checked, because a blank part is not a missing one. | +| 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | +| 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | +| 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | +| 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | +| 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. + +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. + +Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. + +### Step 2 — Required reading (do this before you write any code) + +The contract sheet ends with a **REQUIRED READING** block, and its rows carry inline `MUST load ` pointers. **Load every `dotnet-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. If the sheet names none, load `dotnet-error-handling` anyway — every integration writes an error boundary. + +These are API-agnostic usage skills; loading them is not the same as reading the map. Contract *facts* still come only from the sheet or a map lookup. + +Before implementing, check the plan's **Assumptions & Blockers** section: + +- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `tesla-fleet-management-api-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. +- Minor assumptions only → proceed. + +Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. + +### Step 3 — Implement from the contract sheet + +1. Read `tesla-fleet-management-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. +2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. +3. After every change: `dotnet build`; fix non-SDK errors yourself. +4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `TeslaFleetManagementApi.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. + +### Step 4 — Fixing SDK errors (map-first, in place) + +A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `TeslaFleetManagementApi.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. + +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/TeslaFleetManagementApi/Models/{Type}.cs`, `src/TeslaFleetManagementApi/Models/Enums/{Type}.cs`, `src/TeslaFleetManagementApi/Errors/{Type}.cs`, `src/TeslaFleetManagementApi/Requests/{Type}.cs`, or `src/TeslaFleetManagementApi/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. +3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". +4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). +5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. +6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. + +If a fix corrects a row in `tesla-fleet-management-api-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. + +### Step 5 — Pure questions + +A standalone Tesla Fleet Management API question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. + +## Anti-patterns — never do these + +- **Never write a Tesla Fleet Management API/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** +- **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. +- **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. +- **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. +- **Do not web-search Tesla Fleet Management API for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `TeslaFleetManagementApi` is what the generated source declares, not what public docs describe. +- **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. + diff --git a/plugins/tesla/skills/dotnet/dotnet-models/SKILL.md b/plugins/tesla/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/tesla/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/tesla/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/tesla/skills/dotnet/dotnet-models/reference.md b/plugins/tesla/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/tesla/skills/dotnet/dotnet-models/reference.md +++ b/plugins/tesla/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/tesla/skills/dotnet/dotnet-testing/SKILL.md b/plugins/tesla/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/tesla/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/tesla/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/tesla/skills/python/python-authentication/SKILL.md b/plugins/tesla/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/tesla/skills/python/python-authentication/SKILL.md +++ b/plugins/tesla/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/tesla/skills/python/python-calling-endpoints/SKILL.md b/plugins/tesla/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/tesla/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/tesla/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/tesla/skills/python/python-client-initialization/SKILL.md b/plugins/tesla/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/tesla/skills/python/python-client-initialization/SKILL.md +++ b/plugins/tesla/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/tesla/skills/python/python-configuration-resilience/SKILL.md b/plugins/tesla/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/tesla/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/tesla/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/tesla/skills/python/python-error-handling/SKILL.md b/plugins/tesla/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/tesla/skills/python/python-error-handling/SKILL.md +++ b/plugins/tesla/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/tesla/skills/python/python-event-streams/SKILL.md b/plugins/tesla/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/tesla/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/tesla/skills/python/python-file-handling/SKILL.md b/plugins/tesla/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/tesla/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/tesla/skills/python/python-getting-started/SKILL.md b/plugins/tesla/skills/python/python-getting-started/SKILL.md index d7642a9ec..313cef6e2 100644 --- a/plugins/tesla/skills/python/python-getting-started/SKILL.md +++ b/plugins/tesla/skills/python/python-getting-started/SKILL.md @@ -5,7 +5,7 @@ description: "Tesla Fleet Management API Python SDK identity and lookup layer (P # Getting started with the Tesla Fleet Management API Python SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Tesla Fleet Management API Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-tesla` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Tesla Fleet Management API Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-tesla-fleet-management-api` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated Python SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `python-client-initialization`, `python-authentication`, `python-calling-endpoints`, `python-models`, `python-error-handling`, `python-configuration-resilience` and `python-testing`. @@ -25,12 +25,12 @@ Verified against `tesla_fleet_management_api/` and `pyproject.toml` of the gener | Version | `1.0.0` | | Sync client class | `TeslaFleetManagementApiClient` (alias `Client`) | | Async client class | `AsyncTeslaFleetManagementApiClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `environment` · `base_url` · `timeout` (default `30.0`) · `bearer_auth` · `thirdpartytoken_authorization_code` · `thirdpartytoken_authorization_code_token_source` · `thirdpartytoken_client_credentials` · `thirdpartytoken_client_credentials_token_source`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Client construction | **keyword-only**: `environment` · `base_url` · `timeout` (default `30.0`) · `retry_options` · `bearer_auth` · `thirdpartytoken_authorization_code` · `thirdpartytoken_authorization_code_token_source` · `thirdpartytoken_client_credentials` · `thirdpartytoken_client_credentials_token_source`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | **Bearer** token — set `bearer_auth` · **OAuth 2.0** authorization code — set `thirdpartytoken_authorization_code` · **OAuth 2.0** client credentials — set `thirdpartytoken_client_credentials` | | Environments | 2 environments selected by `environment` (default `"production"`), overridable with `base_url` | | Base-URL config | `ServerConfig` (`tesla_fleet_management_api/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | | Surface | 64 operations across 6 controllers · 84 models · 0 unions · 4 enums · 0 per-operation error unions | @@ -39,13 +39,18 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash pip install "tesla-fleet-management-api @ git+https://github.com/context-plugins/tesla-python-sdk.git@main" +python -c "import tesla_fleet_management_api, pathlib; print(pathlib.Path(tesla_fleet_management_api.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules @@ -151,15 +156,15 @@ The SDK map's controller table carries the same counts and links to a page per c The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `tesla_fleet_management_api/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `tesla_fleet_management_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `tesla_fleet_management_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/tesla-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. Every `Source` path on the map is relative to that SDK root, so `tesla_fleet_management_api/models/actuate_trunk_request.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. @@ -179,7 +184,7 @@ Read the one module that owns the fact **inside the installed package**. Locate python -c "import tesla_fleet_management_api, pathlib; print(pathlib.Path(tesla_fleet_management_api.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/tesla_fleet_management_api` on Windows, `.venv/lib/python3.*/site-packages/tesla_fleet_management_api` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/tesla_fleet_management_api` on Windows, `.venv/lib/python3.*/site-packages/tesla_fleet_management_api` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -187,7 +192,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/te | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -197,7 +202,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/te | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | @@ -213,13 +218,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The three schemes are `bearer_auth=`, `thirdpartytoken_authorization_code=` and `thirdpartytoken_client_credentials=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; the token is fetched lazily and cached by the client; and a **failed token fetch** raises something other than what a caller expects and **bypasses the non-raising response mode** entirely. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; **2 operations return `None`**, so `with_raw_response` is the only way to observe their status code; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`), so an unknown wire value passes through as a plain `str` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -230,8 +235,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 3. **The 2 operations that return `None`** — `charging.get_charging_invoice` · `vehicles.set_enterprise_payer_roles`. Their raw peers are `ApiResult[None, …]`, so `with_raw_response` is the only way to observe the status code. 4. **Required vs `UNSET`** for every model member the task sets, and the fact that `Optional[T]` here is `T | UnsetType` — **not** `typing.Optional`, so `None` is not a legal value for it. 5. **The `ApiError.error` union** for each operation in scope — no operation in this SDK documents a typed error body, so `.error` is always `RawError`. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 19 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"` of the 2 declared. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 19 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"` of the 2 declared. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/tesla/skills/python/python-integrate-tesla/SKILL.md b/plugins/tesla/skills/python/python-integrate-tesla-fleet-management-api/SKILL.md similarity index 81% rename from plugins/tesla/skills/python/python-integrate-tesla/SKILL.md rename to plugins/tesla/skills/python/python-integrate-tesla-fleet-management-api/SKILL.md index 9aa957214..e2a7529d6 100644 --- a/plugins/tesla/skills/python/python-integrate-tesla/SKILL.md +++ b/plugins/tesla/skills/python/python-integrate-tesla-fleet-management-api/SKILL.md @@ -1,81 +1,97 @@ ---- -name: "python-integrate-tesla" -description: "MANDATORY FIRST STEP for Tesla Fleet Management API Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Tesla Fleet Management API in Python — controls the front or rear trunk, briefly flashes vehicle headlights — or when a Tesla Fleet Management API Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." ---- - -# Tesla Fleet Management API Python SDK — Integration workflow (lookup layer + contract sheet) - -You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. - -## Your lookup layer - -- **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. -- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import tesla_fleet_management_api, pathlib; print(pathlib.Path(tesla_fleet_management_api.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) -- **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. -- **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. - -**Scope guard:** the APIMatic-generated Tesla Fleet Management API **Python SDK** (import root `tesla_fleet_management_api`, distribution `tesla-fleet-management-api`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. - -## Workflow - -**If the user opens with a reported SDK error or unexpected Tesla Fleet Management API behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: - -### Step 1 — Plan first (always, for any implementation work) - -Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/tesla-fleet-management-api-plan.md` — that absolute path, not a location you pick later. - -**Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - -- the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `tesla-fleet-management-api` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; -- a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — including which host `environment` will select, since omitting it is silent; -- **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; -- setting up your task tracking. - -Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. - -**HARD GATE — no project-file creation or edits until:** `tesla-fleet-management-api-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. - -**What the gate does not cover:** creating or activating a virtual environment, installing `tesla-fleet-management-api` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. - -### Step 1c — Required reading (do this before you write any code) - -End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. - -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `python-error-handling` always (every integration writes an error boundary); `python-client-initialization` before the client is constructed; `python-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes the transport. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. - -These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a module in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. - -Before implementing, check the plan's **Assumptions & Blockers** section: - -- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `tesla-fleet-management-api-plan.md` in place. -- Minor assumptions only → proceed. - -Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. - -### Step 2 — Implement from the contract sheet - -1. Read `tesla-fleet-management-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). -3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. -4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. -5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `tesla_fleet_management_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. - -### Step 3 — Answering pure questions - -A standalone Tesla Fleet Management API question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. - -## Anti-patterns — never do these - -- **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. -- **Never write a Tesla Fleet Management API/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Don't web-search Tesla Fleet Management API topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Tesla Fleet Management API docs describe the REST API, not this SDK's generated surface, and the two disagree on names. -- **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. -- **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. -- **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. -- **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. - +--- +name: "python-integrate-tesla-fleet-management-api" +description: "MANDATORY FIRST STEP for Tesla Fleet Management API Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Tesla Fleet Management API in Python — controls the front or rear trunk, briefly flashes vehicle headlights — or when a Tesla Fleet Management API Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." +--- + +# Tesla Fleet Management API Python SDK — Integration workflow (lookup layer + contract sheet) + +You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. + +## Your lookup layer + +- **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. +- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import tesla_fleet_management_api, pathlib; print(pathlib.Path(tesla_fleet_management_api.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. +- **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. + +**Scope guard:** the APIMatic-generated Tesla Fleet Management API **Python SDK** (import root `tesla_fleet_management_api`, distribution `tesla-fleet-management-api`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. + +## Workflow + +**If the user opens with a reported SDK error or unexpected Tesla Fleet Management API behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: + +### Step 1 — Plan first (always, for any implementation work) + +Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/tesla-fleet-management-api-plan.md` — that absolute path, not a location you pick later. + +**Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** + +- the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; +- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `tesla-fleet-management-api` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; +- a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; +- credentials/environment verification (per the task's secret-handling rules) — including which host `environment` will select, since omitting it is silent; +- **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; +- setting up your task tracking. + +Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. + +**HARD GATE — no project-file creation or edits until:** `tesla-fleet-management-api-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. + +**What the gate does not cover:** creating or activating a virtual environment, installing `tesla-fleet-management-api` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +### Step 1c — Required reading (do this before you write any code) + +End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. + +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `python-error-handling` always (every integration writes an error boundary); `python-client-initialization` before the client is constructed; `python-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes the transport. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. + +These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a module in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. + +Before implementing, check the plan's **Assumptions & Blockers** section: + +- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `tesla-fleet-management-api-plan.md` in place. +- Minor assumptions only → proceed. + +Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. + +### Step 2 — Implement from the contract sheet + +1. Read `tesla-fleet-management-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. +2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). +3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. +4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. +5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `tesla_fleet_management_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. + +### Step 3 — Answering pure questions + +A standalone Tesla Fleet Management API question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. + +## Anti-patterns — never do these + +- **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. +- **Never write a Tesla Fleet Management API/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** +- **Don't web-search Tesla Fleet Management API topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Tesla Fleet Management API docs describe the REST API, not this SDK's generated surface, and the two disagree on names. +- **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. +- **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. +- **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. +- **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. + diff --git a/plugins/tesla/skills/python/python-models/SKILL.md b/plugins/tesla/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/tesla/skills/python/python-models/SKILL.md +++ b/plugins/tesla/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/tesla/skills/python/python-models/reference.md b/plugins/tesla/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/tesla/skills/python/python-models/reference.md +++ b/plugins/tesla/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/tesla/skills/python/python-testing/SKILL.md b/plugins/tesla/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/tesla/skills/python/python-testing/SKILL.md +++ b/plugins/tesla/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/tesla/skills/typescript/typescript-authentication/SKILL.md b/plugins/tesla/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/tesla/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/tesla/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/tesla/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/tesla/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/tesla/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/tesla/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/tesla/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/tesla/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/tesla/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/tesla/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/tesla/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/tesla/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/tesla/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/tesla/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/tesla/skills/typescript/typescript-error-handling/SKILL.md b/plugins/tesla/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/tesla/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/tesla/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/tesla/skills/typescript/typescript-getting-started/SKILL.md b/plugins/tesla/skills/typescript/typescript-getting-started/SKILL.md index e986cad85..634aa5930 100644 --- a/plugins/tesla/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/tesla/skills/typescript/typescript-getting-started/SKILL.md @@ -5,7 +5,7 @@ description: "Tesla Fleet Management API TypeScript SDK identity and lookup laye # Getting started with the Tesla Fleet Management API TypeScript SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Tesla Fleet Management API TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-tesla` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Tesla Fleet Management API TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-tesla-fleet-management-api` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated TypeScript SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `typescript-client-initialization`, `typescript-authentication`, `typescript-calling-endpoints`, `typescript-models`, `typescript-error-handling`, `typescript-configuration-resilience` and `typescript-testing`. @@ -20,15 +20,17 @@ Verified against `package.json` and `sdk-map.md` of the generated package at ver | API | Tesla Fleet Management API | | Package name (what you install, and what you import) | `tesla-fleet-management-api` — **not on npm**; built from source (see *Install*) | | Import specifier | `tesla-fleet-management-api` — the package root is the **only** entry; deep imports do not resolve | +| Source repository | https://github.com/context-plugins/tesla-typescript-sdk (branch `main` — the ref this map documents) | | Version | `1.0.0` (API spec version `1.0.0`) | | Client class | `TeslaFleetManagementApiClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new TeslaFleetManagementApiClient(clientOptions: Partial = {})` — **every** field is optional, so `new TeslaFleetManagementApiClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `bearerAuth` · `thirdpartytokenAuthorizationCode` · `thirdpartytokenAuthorizationCodeStrategy` · `thirdpartytokenClientCredentials` · `thirdpartytokenClientCredentialsStrategy`. `timeout` defaults to `60_000` ms | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new TeslaFleetManagementApiClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new TeslaFleetManagementApiClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `retry` · `fetch` · `bearerAuth` · `thirdpartytokenAuthorizationCode` · `thirdpartytokenAuthorizationCodeStrategy` · `thirdpartytokenClientCredentials` · `thirdpartytokenClientCredentialsStrategy`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **Bearer token** — set `ClientOptions.bearerAuth`
**OAuth 2 authorization code** — set `ClientOptions.thirdpartytokenAuthorizationCode`
**OAuth 2 client credentials** — set `ClientOptions.thirdpartytokenClientCredentials` | | Environments | 2 environments (`ServerEnvironment.Production` *(default)*, `ServerEnvironment.Environment2`) × 1 server group | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`), defaulting to `https://fleet-api.prd.na.vn.cloud.tesla.com` | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Base-URL config | `serverOptions.baseUrl` (`src/servers.ts`), defaulting to `https://fleet-api.prd.na.vn.cloud.tesla.com` | +| Log environment variable | `TESLA_FLEET_MANAGEMENT_API_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Surface | 64 operations · 6 resources · 84 models · 4 open enums · 0 per-operation error subclasses | @@ -37,22 +39,22 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/tesla-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 88 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 88 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { TeslaFleetManagementApiClient, ServerEnvironment, ResponseError, TeslaFleetManagementApiError } from "tesla-fleet-management-api"; +import { TeslaFleetManagementApiClient, ServerEnvironment, ApiError, TeslaFleetManagementApiError } from "tesla-fleet-management-api"; import type { ClientOptions, ActuateTrunkRequest } from "tesla-fleet-management-api"; ``` @@ -72,15 +74,15 @@ Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, | Group | Environment | Base URL | Override at | | --- | --- | --- | --- | -| `default` | `production` *(default)* | `https://fleet-api.prd.na.vn.cloud.tesla.com` | `serverOptions.default.production.baseUrl` | -| `default` | `environment2` | `https://auth.tesla.com/oauth2/v3` | `serverOptions.default.environment2.baseUrl` | +| `default` | `production` *(default)* | `https://fleet-api.prd.na.vn.cloud.tesla.com` | `serverOptions.baseUrl` | +| `default` | `environment2` | `https://auth.tesla.com/oauth2/v3` | `serverOptions.baseUrl` | Consequences to state on every contract sheet that touches configuration: - Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. +- An environment value the SDK does not know throws `ConfigurationError` **from the constructor** — every server group is resolved once, as the client is built — so no operation method throws synchronously. ## Auth pattern (3 schemes) @@ -106,9 +108,9 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. -**OAuth 2 fetches and caches its own token.** The token request goes through **the same client** — same `timeout`, same `fetch` — sends a form-urlencoded body, and decodes the response against a schema rather than casting it. A token is cached until shortly before it expires; a response with no `expires_in` is treated as never expiring (RFC 6749 §5.1); concurrent callers share one in-flight fetch. **A refused token endpoint rejects with `AuthError`**, wrapping the underlying `ResponseError` as `cause` — so a bad secret never looks like the business call failing, and `AuthError` is *not* a `ResponseError`. +**OAuth 2 fetches and caches its own token.** The token request goes through **the same client** — same `fetch`, same `retry` policy — sends a form-urlencoded body, and decodes the response against a schema rather than casting it. A token is cached until shortly before it expires; a response with no `expires_in` is treated as never expiring (RFC 6749 §5.1); concurrent callers share one in-flight fetch. **A refused token endpoint rejects with `AuthError`**, wrapping the underlying `ApiError` as `cause` — so a bad secret never looks like the business call failing, and `AuthError` is *not* an `ApiError`. The token request is itself a `POST` with its own timeout, retried only when `httpMethodsToRetry` names `POST`. | Flow | Token endpoint | Client credentials travel | | --- | --- | --- | @@ -134,7 +136,7 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | `client.vehicles` | `Vehicles` | 21 | `configureFleetTelemetryUsingSignedJwsToken` · `createOrUpdateFleetTelemetryConfiguration` · `deleteFleetTelemetryConfiguration` · `getAllowedDriversForAVehicle` · `getEligibleVehicleSubscriptions` · `getEligibleVehicleUpgrades` · `getEnterpriseRolesForAVehicle` · `getFleetStatusForVehicles` · `getFleetTelemetryConfiguration` · `getFleetTelemetryErrorsForAVehicle` · `getVehicle` · `listVehicles` · `mobileEnabled` · `nearbyChargingSites` · `removeDriverAccessFromAVehicle` · `setEnterprisePayerRoles` · `vehicleLiveData` · `vehicleOptions` · `vehicleSpecs` · `wakeUpVehicle` · `warrantyDetails` | | `client.vehicleCommands` | `VehicleCommands` | 21 | `actuatetrunk` · `addchargeschedule` · `addpreconditionschedule` · `adjustmediavolume` · `cancelsoftwareupdate` · `chargemaxrange` · `chargestandard` · `clearPiNtoDriveAdmin` · `closechargeportdoor` · `enableordisableGuestMode` · `eraseuserdata` · `flashlights` · `honkhorn` · `lockdoors` · `nextfavoritemediatrack` · `openchargeportdoor` · `startcharging` · `startclimatepreconditioning` · `stopcharging` · `stopclimatepreconditioning` · `unlockdoors` | -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** A name that collides inside its namespace is promoted to `RequestParams` instead — no operation in this SDK takes that spelling at this version. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -142,7 +144,7 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: @@ -151,11 +153,11 @@ Locate the installed package before you rely on a lookup: node -e "console.log(require.resolve('tesla-fleet-management-api/package.json'))" ``` -Failing that it is at `node_modules/tesla-fleet-management-api/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/tesla-fleet-management-api/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/tesla-fleet-management-api/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -163,7 +165,7 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/tesla-fleet-management-api/`: @@ -173,13 +175,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -193,17 +197,23 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `TESLA_FLEET_MANAGEMENT_API_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new TeslaFleetManagementApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters and the OAuth 2 token cache live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) -2. **Authentication** — load **typescript-authentication** before you set credentials. The 3 schemes are `bearerAuth`, `thirdpartytokenAuthorizationCode`, `thirdpartytokenClientCredentials` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; the token is fetched lazily and cached on the client; a **failed token fetch** raises `AuthError`, which is not a `ResponseError` and **bypasses `.asApiResult()` entirely**; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new TeslaFleetManagementApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters and the OAuth 2 token cache live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) +2. **Authentication** — load **typescript-authentication** before you set credentials. The 3 schemes are `bearerAuth`, `thirdpartytokenAuthorizationCode`, `thirdpartytokenClientCredentials` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; the token is fetched lazily and cached on the client; a **failed token fetch** raises `AuthError`, which is not an `ApiError` and **bypasses `.asApiResult()` entirely**; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **2 operations resolve to `undefined` (`charging.getChargingInvoice`, `vehicles.setEnterprisePayerRoles`)**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `TeslaFleetManagementApiError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `TeslaFleetManagementApiError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `TESLA_FLEET_MANAGEMENT_API_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK @@ -212,11 +222,13 @@ Beyond the usual signatures and model members, a contract sheet for the Tesla Fl 1. **Which host each deployment talks to**, and where that is set. The members are `ServerEnvironment.Production`, `ServerEnvironment.Environment2`, defaulting to `ServerEnvironment.Production` when the field is unset. 2. **2 operations resolve to `undefined` (`charging.getChargingInvoice`, `vehicles.setEnterprisePayerRoles`)** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. No operation declares a typed error body, so all 64 reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. **That `Event`, `Response` imported from this package are model types, not the globals of those names** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `TeslaFleetManagementApiError` for the same reason. -10. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. No operation declares a typed error body, so all 64 reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. **That `Event`, `Response` imported from this package are model types, not the globals of those names** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `TeslaFleetManagementApiError` for the same reason. +12. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/tesla/skills/typescript/typescript-integrate-tesla/SKILL.md b/plugins/tesla/skills/typescript/typescript-integrate-tesla-fleet-management-api/SKILL.md similarity index 54% rename from plugins/tesla/skills/typescript/typescript-integrate-tesla/SKILL.md rename to plugins/tesla/skills/typescript/typescript-integrate-tesla-fleet-management-api/SKILL.md index 6a222339e..51b8d0698 100644 --- a/plugins/tesla/skills/typescript/typescript-integrate-tesla/SKILL.md +++ b/plugins/tesla/skills/typescript/typescript-integrate-tesla-fleet-management-api/SKILL.md @@ -1,5 +1,5 @@ --- -name: "typescript-integrate-tesla" +name: "typescript-integrate-tesla-fleet-management-api" description: "MANDATORY FIRST STEP for Tesla Fleet Management API TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Tesla Fleet Management API in TypeScript/Node — get charging history, get charging invoice, get charging sessions, adjust site sbackup reserve, adjust site soff grid vehicle charging reserve, allow disallow charging from the grid and exporting energy to the grid, or when the Tesla Fleet Management API TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `tesla-fleet-management-api` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (tesla-fleet-management-api-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — constructing the client with no options silently selects ServerEnvironment.Production, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." --- @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/tesla-fleet-management-api/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('tesla-fleet-management-api/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('tesla-fleet-management-api/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. @@ -23,7 +23,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/tesla-fleet-management-api-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/tesla-fleet-management-api-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** @@ -40,11 +40,85 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** installing `tesla-fleet-management-api` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. @@ -63,8 +137,10 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from the **2 operations that resolve to `undefined`** (`charging.getChargingInvoice`, `vehicles.setEnterprisePayerRoles`). `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `TeslaFleetManagementApiError` family, told apart by `err.kind` — and an unknown `serverEnvironment` throws `ConfigurationError` out of the client constructor, before any call exists. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `TESLA_FLEET_MANAGEMENT_API_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions @@ -74,7 +150,7 @@ A standalone Tesla Fleet Management API question with no code change: look it up - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. - **Never write any Tesla Fleet Management API or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. - **Don't web-search Tesla Fleet Management API topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Tesla Fleet Management API docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. diff --git a/plugins/tesla/skills/typescript/typescript-models/SKILL.md b/plugins/tesla/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/tesla/skills/typescript/typescript-models/SKILL.md +++ b/plugins/tesla/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/tesla/skills/typescript/typescript-models/reference.md b/plugins/tesla/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/tesla/skills/typescript/typescript-models/reference.md +++ b/plugins/tesla/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/tesla/skills/typescript/typescript-testing/SKILL.md b/plugins/tesla/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/tesla/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/tesla/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/twilio/.claude-plugin/plugin.json b/plugins/twilio/.claude-plugin/plugin.json index c36fae0f0..9c25d20d1 100644 --- a/plugins/twilio/.claude-plugin/plugin.json +++ b/plugins/twilio/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "twilio", "displayName": "Twilio SDK", - "version": "0.3.4", + "version": "0.4.0", "description": "Integrate and consume the APIMatic-generated Twilio SDK SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/twilio/.cursor-plugin/plugin.json b/plugins/twilio/.cursor-plugin/plugin.json index c36fae0f0..9c25d20d1 100644 --- a/plugins/twilio/.cursor-plugin/plugin.json +++ b/plugins/twilio/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "twilio", "displayName": "Twilio SDK", - "version": "0.3.4", + "version": "0.4.0", "description": "Integrate and consume the APIMatic-generated Twilio SDK SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/twilio/plugin.json b/plugins/twilio/plugin.json index c36fae0f0..9c25d20d1 100644 --- a/plugins/twilio/plugin.json +++ b/plugins/twilio/plugin.json @@ -1,7 +1,7 @@ { "name": "twilio", "displayName": "Twilio SDK", - "version": "0.3.4", + "version": "0.4.0", "description": "Integrate and consume the APIMatic-generated Twilio SDK SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/twilio/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/twilio/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..875322e24 100644 --- a/plugins/twilio/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/twilio/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,245 +1,76 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Twilio SDK .NET SDK in C# — this API authenticates with HTTP Basic, and this skill gives the credential each scheme takes, the TwilioSdkClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Twilio SDK .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with HTTP Basic. APIMatic surfaces each scheme as a **nullable credentials property** on `TwilioSdkClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) +| Scheme | Property on `TwilioSdkClientOptions` | Credential | +| --- | --- | --- | +| HTTP Basic | `AccountSidAuthToken` | `BasicAuthCredentials` | -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). +**That table is the entire authentication surface of this SDK.** `TwilioSdk.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. ## Basic auth -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` - -Sends `Authorization: Basic base64(username:password)`. - -## Bearer token - -Set the configured token property on the options class to your access-token string: - -```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; -``` - -Sends `Authorization: Bearer ACCESS_TOKEN`. - -## API key (header, query, or cookie) - -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: - -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` +**API Provider Notes:** -## OAuth 2.0 — client credentials (machine-to-machine) +> This API uses [basic authentication](https://www.twilio.com/docs/glossary/what-is-basic-authentication). Use an [API key](https://www.twilio.com/docs/iam/api-keys) as the username and the API key secret as the password. You can also use your account SID and auth token, but limit their use to local testing. ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; +using TwilioSdk.Core.Authentication.Basic; -options.{OAuthProperty} = new OAuth2ClientCredentials +options.AccountSidAuthToken = new BasicAuthCredentials { - ClientId = "...", - ClientSecret = "...", - Scope = "..." // optional -}; -``` - -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. - -## OAuth 2.0 — authorization code (3-legged, with PKCE) - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; - -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials -{ - ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE - RedirectUri = "https://app.example.com/callback", - Scope = "...", // optional - State = "...", // optional CSRF token - Pkce = PkceMethod.S256, // default; RFC 7636 - PromptForAuthorizationCode = async (authorizationUrl, ct) => - { - // Open/redirect the browser to authorizationUrl, then return the - // authorization code your redirect endpoint received. - return await GetCodeFromUserAsync(authorizationUrl, ct); - } -}; -``` - -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. - -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: - -| PKCE | token request built | no secret ⇒ | -| --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | - -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. - -## OAuth 2.0 — resource owner password - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; - -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional Username = "...", - Password = "...", - Scope = "..." // optional + Password = "..." }; ``` -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. - -### Supplying your own token source - -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. - -```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) -``` - -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: - -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). - -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. - -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +Sends `Authorization: Basic base64(username:password)`. Both halves are required — a username with an empty password is misconfigured, not partially configured. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `TwilioSdkClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("TwilioSdkClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.Username)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "TwilioSdkClient:Username is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"TwilioSdkClient:Username is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. + +Check every value the schemes actually need. For this SDK that is: -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +| Value to validate | Feeds `TwilioSdkClientOptions` property | +| --- | --- | +| `Username` | `AccountSidAuthToken` | +| `Password` | `AccountSidAuthToken` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddTwilioSdkClient(options => ...)` callback when registering via DI. `TwilioSdkClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddTwilioSdkClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/twilio/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/twilio/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/twilio/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/twilio/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/twilio/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/twilio/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/twilio/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/twilio/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/twilio/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/twilio/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/twilio/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/twilio/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/twilio/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/twilio/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/twilio/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/twilio/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException
` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/twilio/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/twilio/skills/dotnet/dotnet-getting-started/SKILL.md index e3258dfe1..97b68d6a6 100644 --- a/plugins/twilio/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/twilio/skills/dotnet/dotnet-getting-started/SKILL.md @@ -1,17 +1,17 @@ --- name: "dotnet-getting-started" -description: "Twilio SDK .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-twilio is the workflow that uses it." +description: "Twilio SDK .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-twilio-sdk is the workflow that uses it." --- # Getting started with the Twilio SDK .NET SDK -> **Who this skill is for.** This is the **map layer**: load it to ground every Twilio SDK SDK fact. `dotnet-integrate-twilio` is the workflow that drives it — you collect the contracts for the work in scope into `twilio-sdk-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. +> **Who this skill is for.** This is the **map layer**: load it to ground every Twilio SDK SDK fact. `dotnet-integrate-twilio-sdk` is the workflow that drives it — you collect the contracts for the work in scope into `twilio-sdk-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated .NET SDK (auth, calling endpoints, models, error handling, retries, testing), see the companion API-agnostic skills: `dotnet-client-initialization`, `dotnet-authentication`, `dotnet-calling-endpoints`, `dotnet-models`, `dotnet-error-handling`, `dotnet-configuration-resilience`, `dotnet-testing`. **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity @@ -26,9 +26,9 @@ This is the **SDK-specific** entry point. For general patterns that apply to any | Auth | HTTP **Basic** — set `AccountSidAuthToken` | | Environments | `ServerEnvironment.Production` *(default)* → `Default` `https://api.twilio.com`, `Default1` `https://messaging.twilio.com`, `Default2` `https://content.twilio.com`, `Default3` `https://verify.twilio.com`, `Default4` `https://lookups.twilio.com`, `Default5` `https://numbers.twilio.com`, `Default6` `https://video.twilio.com`, `Default7` `https://conversations.twilio.com`, `Default8` `https://taskrouter.twilio.com`, `Default9` `https://trusthub.twilio.com`, `Default10` `https://proxy.twilio.com`, `Default11` `https://studio.twilio.com`, `Default12` `https://sync.twilio.com`, `Default13` `https://flex-api.twilio.com`, `Default14` `https://insights.twilio.com` | | Log environment variable | `TWILIOSDKCLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | -The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-twilio` makes that binding); the **Integration workflow** below says which governs which step. +The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-twilio-sdk` makes that binding); the **Integration workflow** below says which governs which step. ## Namespaces (using-directives) @@ -44,22 +44,24 @@ Models, enums, union types and the generated error types each sit in their own c ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Twilio SDK, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/TwilioSdk/Models/`, `src/TwilioSdk/Errors/` and `src/TwilioSdk/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/TwilioSdk/Models/`, enums `src/TwilioSdk/Models/Enums/`, typed errors `src/TwilioSdk/Errors/`, request records `src/TwilioSdk/Requests/`, or `src/TwilioSdk/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: @@ -69,7 +71,7 @@ Keep lookups cheap — the rules that keep a session's context small: ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/twilio-sdk-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/twilio-sdk-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. **Clone once, up front**, into a fresh timestamped folder under `/twilio-sdk-sdk-src/`, and reuse that folder for the rest of your session: @@ -87,30 +89,31 @@ git clone --filter=blob:none --branch main https://github.com/context-plugins/tw # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/twilio-sdk-sdk-src/`, **never** in the project repo, and its path never goes into `twilio-sdk-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `twilio-sdk-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `TwilioSdkClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/TwilioSdk/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/TwilioSdk/Models/` (+ `src/TwilioSdk/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/TwilioSdk/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/TwilioSdk/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/TwilioSdk/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/TwilioSdk/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/TwilioSdk/Servers/`, `src/TwilioSdk/TwilioSdkClient.cs`, `src/TwilioSdk/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Twilio SDK may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything @@ -126,17 +129,17 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/TwilioSdk/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step -You loaded every companion the sheet named before starting (per `dotnet-integrate-twilio`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: +You loaded every companion the sheet named before starting (per `dotnet-integrate-twilio-sdk`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: 1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new TwilioSdkClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) 2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `TwilioSdkClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/twilio/skills/dotnet/dotnet-integrate-twilio/SKILL.md b/plugins/twilio/skills/dotnet/dotnet-integrate-twilio-sdk/SKILL.md similarity index 73% rename from plugins/twilio/skills/dotnet/dotnet-integrate-twilio/SKILL.md rename to plugins/twilio/skills/dotnet/dotnet-integrate-twilio-sdk/SKILL.md index fe490afde..5a74adb3c 100644 --- a/plugins/twilio/skills/dotnet/dotnet-integrate-twilio/SKILL.md +++ b/plugins/twilio/skills/dotnet/dotnet-integrate-twilio-sdk/SKILL.md @@ -1,125 +1,173 @@ ---- -name: "dotnet-integrate-twilio" -description: "MANDATORY FIRST STEP for Twilio SDK .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Twilio SDK in C# — Api20100401Account, Api20100401AddOnResult, Api20100401Address, Api20100401AllTime, Api20100401Application, Api20100401AssignedAddOn, Api20100401AssignedAddOnExtension, Api20100401AuthCallsCredentialListMapping, Api20100401AuthCallsIpAccessControlListMapping, Api20100401AuthRegistrationsCredentialListMapping, Api20100401AuthorizedConnectApp, Api20100401AvailablePhoneNumberCountry, Api20100401Balance, Api20100401Call, Api20100401CallNotification, Api20100401CallRecording, Api20100401CallTranscription, Api20100401Conference, Api20100401ConferenceRecording, Api20100401ConnectApp, Api20100401Credential, Api20100401CredentialList, Api20100401CredentialListMapping, Api20100401Daily, Api20100401Data, Api20100401DependentPhoneNumber, Api20100401Domain, Api20100401Event, Api20100401Feedback, Api20100401IncomingPhoneNumber, Api20100401IncomingPhoneNumberLocal, Api20100401IncomingPhoneNumberMobile, Api20100401IncomingPhoneNumberTollFree, Api20100401IpAccessControlList, Api20100401IpAccessControlListMapping, Api20100401Key, Api20100401LastMonth, Api20100401Local, Api20100401MachineToMachine, Api20100401Media, Api20100401MediaInstance, Api20100401Member, Api20100401Message, Api20100401Mobile, Api20100401Monthly, Api20100401National, Api20100401NewKey, Api20100401NewSigningKey, Api20100401Notification, Api20100401OutgoingCallerId, Api20100401Participant, Api20100401Payload, Api20100401Payment, Api20100401Queue, Api20100401Record, Api20100401Recording, Api20100401RecordingTranscription, Api20100401SharedCost, Api20100401ShortCode, Api20100401SigningKey, Api20100401SipIpAddress, Api20100401Siprec, Api20100401Stream, Api20100401ThisMonth, Api20100401Today, Api20100401Token, Api20100401TollFree, Api20100401Transcription, Api20100401Trigger, Api20100401UserDefinedMessage, Api20100401UserDefinedMessageSubscription, Api20100401ValidationRequest, Api20100401Voip, Api20100401Yearly, Api20100401Yesterday, ContentV2Content, ContentV2ContentAndApprovals, Contentv1ApprovalCreate, Contentv1ApprovalFetch, Contentv1ContentApi, Contentv1ContentAndApprovalsApi, Contentv1LegacyContentApi, ConversationsV1AddressConfiguration, ConversationsV1Binding, ConversationsV1ConfigurationApi, ConversationsV1ConversationApi, ConversationsV1ConversationWithParticipantsApi, ConversationsV1CredentialApi, ConversationsV1DeliveryReceipt, ConversationsV1Message, ConversationsV1Notification, ConversationsV1Participant, ConversationsV1ParticipantConversationApi, ConversationsV1RoleApi, ConversationsV1ServiceApi, ConversationsV1UserApi, ConversationsV1UserConversation, ConversationsV1Webhook, ConversationsV2ActionApi, ConversationsV2CommunicationApi, ConversationsV2ConfigurationApi, ConversationsV2ConversationApi, ConversationsV2Operation, ConversationsV2ParticipantApi, FlexV1Assessments, FlexV1ChannelApi, FlexV1ConfigurationApi, FlexV1ConfiguredPlugin, FlexV1FlexFlowApi, FlexV1InsightsAssessmentsCommentApi, FlexV1InsightsConversationsApi, FlexV1InsightsQuestionnairesApi, FlexV1InsightsQuestionnairesCategoryApi, FlexV1InsightsQuestionnairesQuestionApi, FlexV1InsightsSegmentsApi, FlexV1InsightsSessionApi, FlexV1InsightsSettingsAnswerSetsApi, FlexV1InsightsSettingsCommentApi, FlexV1InsightsUserRolesApi, FlexV1InteractionApi, FlexV1InteractionChannel, FlexV1InteractionChannelInvite, FlexV1InteractionChannelParticipant, FlexV1InteractionTransfer, FlexV1PluginApi, FlexV1PluginArchiveApi, FlexV1PluginConfigurationApi, FlexV1PluginConfigurationArchiveApi, FlexV1PluginReleaseApi, FlexV1PluginVersionArchiveApi, FlexV1PluginVersions, FlexV1ProvisioningStatusApi, FlexV1WebChannelApi, FlexV2FlexUserApi, FlexV2WebChannels, InsightsV1Annotation, InsightsV1CallApi, InsightsV1CallSummariesApi, InsightsV1CallSummaryApi, InsightsV1ConferenceApi, InsightsV1ConferenceParticipant, InsightsV1CreateAccountReport, InsightsV1CreateInboundPhoneNumbersReport, InsightsV1CreateOutboundPhoneNumbersReport, InsightsV1Event, InsightsV1GetAccountReport, InsightsV1GetInboundPhoneNumbersReport, InsightsV1GetOutboundPhoneNumbersReport, InsightsV1Metric, InsightsV1Participant, InsightsV1Room, InsightsV1Setting, LookupsV1PhoneNumberApi, LookupsV2PhoneNumber, MessagingV1AlphaSender, MessagingV1BrandRegistration, MessagingV1BrandRegistrationOtp, MessagingV1BrandVetting, MessagingV1ChannelSender, MessagingV1Deactivations, MessagingV1DestinationAlphaSender, MessagingV1DomainCerts, MessagingV1DomainConfigApi, MessagingV1DomainConfigMessagingServiceApi, MessagingV1DomainValidateDns, MessagingV1ExternalCampaignApi, MessagingV1LinkshorteningMessagingServiceApi, MessagingV1LinkshorteningMessagingServiceDomainAssociationApi, MessagingV1PhoneNumber, MessagingV1RequestManagedCertApi, MessagingV1ServiceApi, MessagingV1ShortCode, MessagingV1TollfreeVerificationApi, MessagingV1UsAppToPerson, MessagingV1UsAppToPersonUsecase, MessagingV1UsecaseApi, MessagingV2ChannelsSender, MessagingV2DomainCerts, MessagingV2TypingIndicator, MessagingV3TypingIndicator, NumbersV1BulkEligibilityApi, NumbersV1EligibilityApi, NumbersV1PortingPortInApi, NumbersV1PortingPortInPhoneNumberApi, NumbersV1PortingPortabilityApi, NumbersV1PortingWebhookConfigurationApi, NumbersV1PortingWebhookConfigurationDeleteApi, NumbersV1PortingWebhookConfigurationFetchApi, NumbersV1SenderIdRegistration, NumbersV1SenderIdRegistrationEmbeddedSession, NumbersV1SigningRequestConfigurationApi, NumbersV2AuthorizationDocumentApi, NumbersV2BulkHostedNumberOrderApi, NumbersV2Bundle, NumbersV2BundleCloneApi, NumbersV2BundleCopy, NumbersV2DependentHostedNumberOrder, NumbersV2EndUser, NumbersV2EndUserType, NumbersV2Evaluation, NumbersV2HostedNumberOrderApi, NumbersV2ItemAssignment, NumbersV2Regulation, NumbersV2ReplaceItems, NumbersV2SupportingDocument, NumbersV2SupportingDocumentType, NumbersV3HostedNumbersHostedNumberOrderApi, ProxyV1Interaction, ProxyV1MessageInteraction, ProxyV1Participant, ProxyV1PhoneNumber, ProxyV1ServiceApi, ProxyV1Session, StudioV1Engagement, StudioV1EngagementContext, StudioV1Execution, StudioV1ExecutionContext, StudioV1ExecutionStep, StudioV1ExecutionStepContext, StudioV1FlowApi, StudioV1Step, StudioV1StepContext, StudioV2Execution, StudioV2ExecutionContext, StudioV2ExecutionStep, StudioV2ExecutionStepContext, StudioV2FlowApi, StudioV2FlowRevision, StudioV2FlowTestUserApi, StudioV2FlowValidateApi, SyncV1Document, SyncV1DocumentPermission, SyncV1ServiceApi, SyncV1StreamMessage, SyncV1SyncList, SyncV1SyncListItem, SyncV1SyncListPermission, SyncV1SyncMap, SyncV1SyncMapItem, SyncV1SyncMapPermission, SyncV1SyncStream, TaskrouterV1Activity, TaskrouterV1Event, TaskrouterV1Task, TaskrouterV1TaskChannel, TaskrouterV1TaskQueue, TaskrouterV1TaskQueueBulkRealTimeStatistics, TaskrouterV1TaskQueueCumulativeStatistics, TaskrouterV1TaskQueueRealTimeStatistics, TaskrouterV1TaskQueueStatistics, TaskrouterV1TaskQueuesStatistics, TaskrouterV1TaskReservation, TaskrouterV1Worker, TaskrouterV1WorkerChannel, TaskrouterV1WorkerReservation, TaskrouterV1WorkerStatistics, TaskrouterV1WorkersCumulativeStatistics, TaskrouterV1WorkersRealTimeStatistics, TaskrouterV1WorkersStatistics, TaskrouterV1Workflow, TaskrouterV1WorkflowCumulativeStatistics, TaskrouterV1WorkflowRealTimeStatistics, TaskrouterV1WorkflowStatistics, TaskrouterV1WorkspaceApi, TaskrouterV1WorkspaceCumulativeStatistics, TaskrouterV1WorkspaceRealTimeStatistics, TaskrouterV1WorkspaceStatistics, TrusthubV1ComplianceInquiries, TrusthubV1ComplianceRegistrationInquiries, TrusthubV1ComplianceTollfreeInquiries, TrusthubV1CustomerProfiles, TrusthubV1CustomerProfilesChannelEndpointAssignment, TrusthubV1CustomerProfilesEntityAssignments, TrusthubV1CustomerProfilesEvaluations, TrusthubV1EndUserApi, TrusthubV1EndUserType, TrusthubV1PoliciesApi, TrusthubV1SupportingDocumentApi, TrusthubV1SupportingDocumentType, TrusthubV1TrustProducts, TrusthubV1TrustProductsChannelEndpointAssignment, TrusthubV1TrustProductsEntityAssignments, TrusthubV1TrustProductsEvaluations, TwilioInsights, V2ShortCodeApplications, VerifyV2AccessToken, VerifyV2Bucket, VerifyV2Challenge, VerifyV2Entity, VerifyV2Factor, VerifyV2FormApi, VerifyV2MessagingConfiguration, VerifyV2NewChallenge, VerifyV2NewFactor, VerifyV2Notification, VerifyV2RateLimit, VerifyV2SafelistApi, VerifyV2ServiceApi, VerifyV2Template, VerifyV2Verification, VerifyV2VerificationAttemptApi, VerifyV2VerificationAttemptsSummaryApi, VerifyV2VerificationCheck, VerifyV2Webhook, VideoV1Anonymize, VideoV1CompositionApi, VideoV1CompositionHookApi, VideoV1CompositionSettingsApi, VideoV1Participant, VideoV1PublishedTrack, VideoV1RecordingApi, VideoV1RecordingRules, VideoV1RecordingSettingsApi, VideoV1RoomApi, VideoV1RoomRecording, VideoV1SubscribeRules, VideoV1SubscribedTrack, VideoV1Transcriptions — or when a Twilio SDK .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file twilio-sdk-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." ---- - -# Twilio SDK .NET SDK — Workflow (map + skills) - -You are the Twilio SDK .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. - -**Scope guard:** the APIMatic-generated Twilio SDK **.NET SDK** (root namespace `TwilioSdk`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. - -## Grounding rules - -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `twilio-sdk-plan.md` — the plan must stay portable. -- **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. -- **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. -- **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. -- **The sheet records Twilio SDK's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. -- **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. - -## Workflow - -**If the user opens with a reported SDK error or unexpected Twilio SDK behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: - -### Step 1 — Plan first: write `twilio-sdk-plan.md` (always, for any implementation work) - -Your FIRST deliverable is `twilio-sdk-plan.md` at `/twilio-sdk-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. - -#### How to ground (map-first, one pass) - -1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. -3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. -4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. -5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. - -#### Prerequisites you can run alongside - -These need no SDK knowledge and touch no project file, so do them before or alongside the map work: - -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); -- the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; -- locating the SDK reference and its version in the project; -- credentials/environment verification (per the task's secret-handling rules); -- setting up your task tracking. - -Never use this prerequisite work as a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. - -#### HARD GATE - -**No project-file creation or edits until `twilio-sdk-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. - -#### The `twilio-sdk-plan.md` format (keep it tight — tables, not prose) - -1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. -3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. -6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. -7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. - -| # | Concern | The decision the plan must record | -| --- | --- | --- | -| 1 | **Credential fail-fast** | Where credentials are bound, and that the host refuses to start when one is missing or blank — rather than discovering it as a 401 on the first call in production. A multi-part credential needs **every** part checked, because a blank part is not a missing one. | -| 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | -| 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | -| 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | -| 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | -| 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | -| 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | - -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. - -Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. - -### Step 2 — Required reading (do this before you write any code) - -The contract sheet ends with a **REQUIRED READING** block, and its rows carry inline `MUST load ` pointers. **Load every `dotnet-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. If the sheet names none, load `dotnet-error-handling` anyway — every integration writes an error boundary. - -These are API-agnostic usage skills; loading them is not the same as reading the map. Contract *facts* still come only from the sheet or a map lookup. - -Before implementing, check the plan's **Assumptions & Blockers** section: - -- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `twilio-sdk-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. -- Minor assumptions only → proceed. - -Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. - -### Step 3 — Implement from the contract sheet - -1. Read `twilio-sdk-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. -2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. -3. After every change: `dotnet build`; fix non-SDK errors yourself. -4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `TwilioSdk.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. - -### Step 4 — Fixing SDK errors (map-first, in place) - -A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `TwilioSdk.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. - -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. -3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". -4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). -5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. -6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. - -If a fix corrects a row in `twilio-sdk-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. - -### Step 5 — Pure questions - -A standalone Twilio SDK question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. - -## Anti-patterns — never do these - -- **Never write a Twilio SDK/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** -- **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. -- **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. -- **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search Twilio SDK for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `TwilioSdk` is what the generated source declares, not what public docs describe. -- **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. - +--- +name: "dotnet-integrate-twilio-sdk" +description: "MANDATORY FIRST STEP for Twilio SDK .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Twilio SDK in C# — Api20100401Account, Api20100401AddOnResult, Api20100401Address, Api20100401AllTime, Api20100401Application, Api20100401AssignedAddOn, Api20100401AssignedAddOnExtension, Api20100401AuthCallsCredentialListMapping, Api20100401AuthCallsIpAccessControlListMapping, Api20100401AuthRegistrationsCredentialListMapping, Api20100401AuthorizedConnectApp, Api20100401AvailablePhoneNumberCountry, Api20100401Balance, Api20100401Call, Api20100401CallNotification, Api20100401CallRecording, Api20100401CallTranscription, Api20100401Conference, Api20100401ConferenceRecording, Api20100401ConnectApp, Api20100401Credential, Api20100401CredentialList, Api20100401CredentialListMapping, Api20100401Daily, Api20100401Data, Api20100401DependentPhoneNumber, Api20100401Domain, Api20100401Event, Api20100401Feedback, Api20100401IncomingPhoneNumber, Api20100401IncomingPhoneNumberLocal, Api20100401IncomingPhoneNumberMobile, Api20100401IncomingPhoneNumberTollFree, Api20100401IpAccessControlList, Api20100401IpAccessControlListMapping, Api20100401Key, Api20100401LastMonth, Api20100401Local, Api20100401MachineToMachine, Api20100401Media, Api20100401MediaInstance, Api20100401Member, Api20100401Message, Api20100401Mobile, Api20100401Monthly, Api20100401National, Api20100401NewKey, Api20100401NewSigningKey, Api20100401Notification, Api20100401OutgoingCallerId, Api20100401Participant, Api20100401Payload, Api20100401Payment, Api20100401Queue, Api20100401Record, Api20100401Recording, Api20100401RecordingTranscription, Api20100401SharedCost, Api20100401ShortCode, Api20100401SigningKey, Api20100401SipIpAddress, Api20100401Siprec, Api20100401Stream, Api20100401ThisMonth, Api20100401Today, Api20100401Token, Api20100401TollFree, Api20100401Transcription, Api20100401Trigger, Api20100401UserDefinedMessage, Api20100401UserDefinedMessageSubscription, Api20100401ValidationRequest, Api20100401Voip, Api20100401Yearly, Api20100401Yesterday, ContentV2Content, ContentV2ContentAndApprovals, Contentv1ApprovalCreate, Contentv1ApprovalFetch, Contentv1ContentApi, Contentv1ContentAndApprovalsApi, Contentv1LegacyContentApi, ConversationsV1AddressConfiguration, ConversationsV1Binding, ConversationsV1ConfigurationApi, ConversationsV1ConversationApi, ConversationsV1ConversationWithParticipantsApi, ConversationsV1CredentialApi, ConversationsV1DeliveryReceipt, ConversationsV1Message, ConversationsV1Notification, ConversationsV1Participant, ConversationsV1ParticipantConversationApi, ConversationsV1RoleApi, ConversationsV1ServiceApi, ConversationsV1UserApi, ConversationsV1UserConversation, ConversationsV1Webhook, ConversationsV2ActionApi, ConversationsV2CommunicationApi, ConversationsV2ConfigurationApi, ConversationsV2ConversationApi, ConversationsV2Operation, ConversationsV2ParticipantApi, FlexV1Assessments, FlexV1ChannelApi, FlexV1ConfigurationApi, FlexV1ConfiguredPlugin, FlexV1FlexFlowApi, FlexV1InsightsAssessmentsCommentApi, FlexV1InsightsConversationsApi, FlexV1InsightsQuestionnairesApi, FlexV1InsightsQuestionnairesCategoryApi, FlexV1InsightsQuestionnairesQuestionApi, FlexV1InsightsSegmentsApi, FlexV1InsightsSessionApi, FlexV1InsightsSettingsAnswerSetsApi, FlexV1InsightsSettingsCommentApi, FlexV1InsightsUserRolesApi, FlexV1InteractionApi, FlexV1InteractionChannel, FlexV1InteractionChannelInvite, FlexV1InteractionChannelParticipant, FlexV1InteractionTransfer, FlexV1PluginApi, FlexV1PluginArchiveApi, FlexV1PluginConfigurationApi, FlexV1PluginConfigurationArchiveApi, FlexV1PluginReleaseApi, FlexV1PluginVersionArchiveApi, FlexV1PluginVersions, FlexV1ProvisioningStatusApi, FlexV1WebChannelApi, FlexV2FlexUserApi, FlexV2WebChannels, InsightsV1Annotation, InsightsV1CallApi, InsightsV1CallSummariesApi, InsightsV1CallSummaryApi, InsightsV1ConferenceApi, InsightsV1ConferenceParticipant, InsightsV1CreateAccountReport, InsightsV1CreateInboundPhoneNumbersReport, InsightsV1CreateOutboundPhoneNumbersReport, InsightsV1Event, InsightsV1GetAccountReport, InsightsV1GetInboundPhoneNumbersReport, InsightsV1GetOutboundPhoneNumbersReport, InsightsV1Metric, InsightsV1Participant, InsightsV1Room, InsightsV1Setting, LookupsV1PhoneNumberApi, LookupsV2PhoneNumber, MessagingV1AlphaSender, MessagingV1BrandRegistration, MessagingV1BrandRegistrationOtp, MessagingV1BrandVetting, MessagingV1ChannelSender, MessagingV1Deactivations, MessagingV1DestinationAlphaSender, MessagingV1DomainCerts, MessagingV1DomainConfigApi, MessagingV1DomainConfigMessagingServiceApi, MessagingV1DomainValidateDns, MessagingV1ExternalCampaignApi, MessagingV1LinkshorteningMessagingServiceApi, MessagingV1LinkshorteningMessagingServiceDomainAssociationApi, MessagingV1PhoneNumber, MessagingV1RequestManagedCertApi, MessagingV1ServiceApi, MessagingV1ShortCode, MessagingV1TollfreeVerificationApi, MessagingV1UsAppToPerson, MessagingV1UsAppToPersonUsecase, MessagingV1UsecaseApi, MessagingV2ChannelsSender, MessagingV2DomainCerts, MessagingV2TypingIndicator, MessagingV3TypingIndicator, NumbersV1BulkEligibilityApi, NumbersV1EligibilityApi, NumbersV1PortingPortInApi, NumbersV1PortingPortInPhoneNumberApi, NumbersV1PortingPortabilityApi, NumbersV1PortingWebhookConfigurationApi, NumbersV1PortingWebhookConfigurationDeleteApi, NumbersV1PortingWebhookConfigurationFetchApi, NumbersV1SenderIdRegistration, NumbersV1SenderIdRegistrationEmbeddedSession, NumbersV1SigningRequestConfigurationApi, NumbersV2AuthorizationDocumentApi, NumbersV2BulkHostedNumberOrderApi, NumbersV2Bundle, NumbersV2BundleCloneApi, NumbersV2BundleCopy, NumbersV2DependentHostedNumberOrder, NumbersV2EndUser, NumbersV2EndUserType, NumbersV2Evaluation, NumbersV2HostedNumberOrderApi, NumbersV2ItemAssignment, NumbersV2Regulation, NumbersV2ReplaceItems, NumbersV2SupportingDocument, NumbersV2SupportingDocumentType, NumbersV3HostedNumbersHostedNumberOrderApi, ProxyV1Interaction, ProxyV1MessageInteraction, ProxyV1Participant, ProxyV1PhoneNumber, ProxyV1ServiceApi, ProxyV1Session, StudioV1Engagement, StudioV1EngagementContext, StudioV1Execution, StudioV1ExecutionContext, StudioV1ExecutionStep, StudioV1ExecutionStepContext, StudioV1FlowApi, StudioV1Step, StudioV1StepContext, StudioV2Execution, StudioV2ExecutionContext, StudioV2ExecutionStep, StudioV2ExecutionStepContext, StudioV2FlowApi, StudioV2FlowRevision, StudioV2FlowTestUserApi, StudioV2FlowValidateApi, SyncV1Document, SyncV1DocumentPermission, SyncV1ServiceApi, SyncV1StreamMessage, SyncV1SyncList, SyncV1SyncListItem, SyncV1SyncListPermission, SyncV1SyncMap, SyncV1SyncMapItem, SyncV1SyncMapPermission, SyncV1SyncStream, TaskrouterV1Activity, TaskrouterV1Event, TaskrouterV1Task, TaskrouterV1TaskChannel, TaskrouterV1TaskQueue, TaskrouterV1TaskQueueBulkRealTimeStatistics, TaskrouterV1TaskQueueCumulativeStatistics, TaskrouterV1TaskQueueRealTimeStatistics, TaskrouterV1TaskQueueStatistics, TaskrouterV1TaskQueuesStatistics, TaskrouterV1TaskReservation, TaskrouterV1Worker, TaskrouterV1WorkerChannel, TaskrouterV1WorkerReservation, TaskrouterV1WorkerStatistics, TaskrouterV1WorkersCumulativeStatistics, TaskrouterV1WorkersRealTimeStatistics, TaskrouterV1WorkersStatistics, TaskrouterV1Workflow, TaskrouterV1WorkflowCumulativeStatistics, TaskrouterV1WorkflowRealTimeStatistics, TaskrouterV1WorkflowStatistics, TaskrouterV1WorkspaceApi, TaskrouterV1WorkspaceCumulativeStatistics, TaskrouterV1WorkspaceRealTimeStatistics, TaskrouterV1WorkspaceStatistics, TrusthubV1ComplianceInquiries, TrusthubV1ComplianceRegistrationInquiries, TrusthubV1ComplianceTollfreeInquiries, TrusthubV1CustomerProfiles, TrusthubV1CustomerProfilesChannelEndpointAssignment, TrusthubV1CustomerProfilesEntityAssignments, TrusthubV1CustomerProfilesEvaluations, TrusthubV1EndUserApi, TrusthubV1EndUserType, TrusthubV1PoliciesApi, TrusthubV1SupportingDocumentApi, TrusthubV1SupportingDocumentType, TrusthubV1TrustProducts, TrusthubV1TrustProductsChannelEndpointAssignment, TrusthubV1TrustProductsEntityAssignments, TrusthubV1TrustProductsEvaluations, TwilioInsights, V2ShortCodeApplications, VerifyV2AccessToken, VerifyV2Bucket, VerifyV2Challenge, VerifyV2Entity, VerifyV2Factor, VerifyV2FormApi, VerifyV2MessagingConfiguration, VerifyV2NewChallenge, VerifyV2NewFactor, VerifyV2Notification, VerifyV2RateLimit, VerifyV2SafelistApi, VerifyV2ServiceApi, VerifyV2Template, VerifyV2Verification, VerifyV2VerificationAttemptApi, VerifyV2VerificationAttemptsSummaryApi, VerifyV2VerificationCheck, VerifyV2Webhook, VideoV1Anonymize, VideoV1CompositionApi, VideoV1CompositionHookApi, VideoV1CompositionSettingsApi, VideoV1Participant, VideoV1PublishedTrack, VideoV1RecordingApi, VideoV1RecordingRules, VideoV1RecordingSettingsApi, VideoV1RoomApi, VideoV1RoomRecording, VideoV1SubscribeRules, VideoV1SubscribedTrack, VideoV1Transcriptions — or when a Twilio SDK .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file twilio-sdk-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." +--- + +# Twilio SDK .NET SDK — Workflow (map + skills) + +You are the Twilio SDK .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. + +**Scope guard:** the APIMatic-generated Twilio SDK **.NET SDK** (root namespace `TwilioSdk`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. + +## Grounding rules + +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `twilio-sdk-plan.md` never carries its path — the plan must stay portable. +- **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. +- **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. +- **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. +- **The sheet records Twilio SDK's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. +- **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. + +## Workflow + +**If the user opens with a reported SDK error or unexpected Twilio SDK behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: + +### Step 1 — Plan first: write `twilio-sdk-plan.md` (always, for any implementation work) + +Your FIRST deliverable is `twilio-sdk-plan.md` at `/twilio-sdk-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. + +#### How to ground (map-first, one pass) + +1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. +3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. +4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. +5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. + +#### Prerequisites you can run alongside + +These need no SDK knowledge and touch no project file, so do them before or alongside the map work: + +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; +- `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; +- locating the SDK reference and its version in the project; +- credentials/environment verification (per the task's secret-handling rules); +- setting up your task tracking. + +Never use this prerequisite work as a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. + +#### HARD GATE + +**No project-file creation or edits until `twilio-sdk-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. + +#### The `twilio-sdk-plan.md` format (keep it tight — tables, not prose) + +1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + +3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. +7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. + +| # | Concern | The decision the plan must record | +| --- | --- | --- | +| 1 | **Credential fail-fast** | Where credentials are bound, and that the host refuses to start when one is missing or blank — rather than discovering it as a 401 on the first call in production. A multi-part credential needs **every** part checked, because a blank part is not a missing one. | +| 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | +| 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | +| 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | +| 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | +| 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. + +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. + +Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. + +### Step 2 — Required reading (do this before you write any code) + +The contract sheet ends with a **REQUIRED READING** block, and its rows carry inline `MUST load ` pointers. **Load every `dotnet-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. If the sheet names none, load `dotnet-error-handling` anyway — every integration writes an error boundary. + +These are API-agnostic usage skills; loading them is not the same as reading the map. Contract *facts* still come only from the sheet or a map lookup. + +Before implementing, check the plan's **Assumptions & Blockers** section: + +- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `twilio-sdk-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. +- Minor assumptions only → proceed. + +Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. + +### Step 3 — Implement from the contract sheet + +1. Read `twilio-sdk-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. +2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. +3. After every change: `dotnet build`; fix non-SDK errors yourself. +4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `TwilioSdk.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. + +### Step 4 — Fixing SDK errors (map-first, in place) + +A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `TwilioSdk.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. + +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/TwilioSdk/Models/{Type}.cs`, `src/TwilioSdk/Models/Enums/{Type}.cs`, `src/TwilioSdk/Errors/{Type}.cs`, `src/TwilioSdk/Requests/{Type}.cs`, or `src/TwilioSdk/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. +3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". +4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). +5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. +6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. + +If a fix corrects a row in `twilio-sdk-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. + +### Step 5 — Pure questions + +A standalone Twilio SDK question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. + +## Anti-patterns — never do these + +- **Never write a Twilio SDK/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** +- **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. +- **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. +- **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. +- **Do not web-search Twilio SDK for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `TwilioSdk` is what the generated source declares, not what public docs describe. +- **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. + diff --git a/plugins/twilio/skills/dotnet/dotnet-models/SKILL.md b/plugins/twilio/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/twilio/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/twilio/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/twilio/skills/dotnet/dotnet-models/reference.md b/plugins/twilio/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/twilio/skills/dotnet/dotnet-models/reference.md +++ b/plugins/twilio/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/twilio/skills/dotnet/dotnet-testing/SKILL.md b/plugins/twilio/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/twilio/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/twilio/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/twilio/skills/python/python-authentication/SKILL.md b/plugins/twilio/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/twilio/skills/python/python-authentication/SKILL.md +++ b/plugins/twilio/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/twilio/skills/python/python-calling-endpoints/SKILL.md b/plugins/twilio/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/twilio/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/twilio/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/twilio/skills/python/python-client-initialization/SKILL.md b/plugins/twilio/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/twilio/skills/python/python-client-initialization/SKILL.md +++ b/plugins/twilio/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/twilio/skills/python/python-configuration-resilience/SKILL.md b/plugins/twilio/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/twilio/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/twilio/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/twilio/skills/python/python-error-handling/SKILL.md b/plugins/twilio/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/twilio/skills/python/python-error-handling/SKILL.md +++ b/plugins/twilio/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/twilio/skills/python/python-event-streams/SKILL.md b/plugins/twilio/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/twilio/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/twilio/skills/python/python-file-handling/SKILL.md b/plugins/twilio/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/twilio/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/twilio/skills/python/python-getting-started/SKILL.md b/plugins/twilio/skills/python/python-getting-started/SKILL.md index 0e15c8a47..2d6f1f113 100644 --- a/plugins/twilio/skills/python/python-getting-started/SKILL.md +++ b/plugins/twilio/skills/python/python-getting-started/SKILL.md @@ -5,7 +5,7 @@ description: "Twilio SDK Python SDK identity and lookup layer (Python only) — # Getting started with the Twilio SDK Python SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Twilio SDK Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-twilio` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Twilio SDK Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-twilio-sdk` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated Python SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `python-client-initialization`, `python-authentication`, `python-calling-endpoints`, `python-models`, `python-error-handling`, `python-configuration-resilience` and `python-testing`. @@ -25,12 +25,12 @@ Verified against `twilio_sdk/` and `pyproject.toml` of the generated package at | Version | `1.0.0` | | Sync client class | `TwilioSdkClient` (alias `Client`) | | Async client class | `AsyncTwilioSdkClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `server_config` · `timeout` (default `30.0`) · `account_sid_auth_token`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Client construction | **keyword-only**: `server_config` · `timeout` (default `30.0`) · `retry_options` · `account_sid_auth_token`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | HTTP **Basic** — set `account_sid_auth_token` | | Environments | one environment; 15 named servers configured through `server_config` | | Base-URL config | `ServerConfig` (`twilio_sdk/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | | Surface | 898 operations across 318 controllers, plus 11 directly on the client (`map/operations/client.md`) · 838 models · 4 unions · 399 enums · 37 per-operation error unions | @@ -39,13 +39,18 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash pip install "twilio-sdk @ git+https://github.com/context-plugins/twilio-python-sdk.git@main" +python -c "import twilio_sdk, pathlib; print(pathlib.Path(twilio_sdk.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules @@ -454,15 +459,15 @@ The SDK map's controller table carries the same counts and links to a page per c The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `twilio_sdk/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `twilio_sdk/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `twilio_sdk/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/twilio-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. Every `Source` path on the map is relative to that SDK root, so `twilio_sdk/models/accounts_calls_recordings_sid_json201041408_error.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. @@ -482,7 +487,7 @@ Read the one module that owns the fact **inside the installed package**. Locate python -c "import twilio_sdk, pathlib; print(pathlib.Path(twilio_sdk.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/twilio_sdk` on Windows, `.venv/lib/python3.*/site-packages/twilio_sdk` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/twilio_sdk` on Windows, `.venv/lib/python3.*/site-packages/twilio_sdk` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -490,7 +495,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/tw | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -500,7 +505,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/tw | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | | An operation's error mapper (status → schema) | `errors/_error.py` | @@ -517,13 +522,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The one scheme is `account_sid_auth_token=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; **128 operations return `None`**, so `with_raw_response` is the only way to observe their status code; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`), so an unknown wire value passes through as a plain `str` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -543,8 +548,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 7. `V3InsightsDomainsConversationsMetadata500Error1` — 1 operation (`twilio_insights.fetch_metadata`); distinguishing members `code` · `message` · `http_status_code` 8. *(none)* — `create_bulk_lookup` · `update_challenge_passkeys` · `update_passkeys_factor` · `api20100401_account.create_account` · `api20100401_account.fetch_account` · `api20100401_account.list_account` · `api20100401_account.update_account` · `api20100401_add_on_result.delete_recording_add_on_result` · `api20100401_add_on_result.fetch_recording_add_on_result` · `api20100401_add_on_result.list_recording_add_on_result` · `api20100401_address.create_address` · `api20100401_address.delete_address` · `api20100401_address.fetch_address` · `api20100401_address.list_address` · `api20100401_address.update_address` · `api20100401_all_time.list_usage_record_all_time` · `api20100401_application.create_application` · `api20100401_application.delete_application` · `api20100401_application.fetch_application` · `api20100401_application.list_application` · `api20100401_application.update_application` · `api20100401_assigned_add_on.create_incoming_phone_number_assigned_add_on` · `api20100401_assigned_add_on.delete_incoming_phone_number_assigned_add_on` · `api20100401_assigned_add_on.fetch_incoming_phone_number_assigned_add_on` · `api20100401_assigned_add_on.list_incoming_phone_number_assigned_add_on` · `api20100401_assigned_add_on_extension.fetch_incoming_phone_number_assigned_add_on_extension` · `api20100401_assigned_add_on_extension.list_incoming_phone_number_assigned_add_on_extension` · `api20100401_auth_calls_credential_list_mapping.create_sip_auth_calls_credential_list_mapping` · `api20100401_auth_calls_credential_list_mapping.delete_sip_auth_calls_credential_list_mapping` · `api20100401_auth_calls_credential_list_mapping.fetch_sip_auth_calls_credential_list_mapping` · `api20100401_auth_calls_credential_list_mapping.list_sip_auth_calls_credential_list_mapping` · `api20100401_auth_calls_ip_access_control_list_mapping.create_sip_auth_calls_ip_access_control_list_mapping` · `api20100401_auth_calls_ip_access_control_list_mapping.delete_sip_auth_calls_ip_access_control_list_mapping` · `api20100401_auth_calls_ip_access_control_list_mapping.fetch_sip_auth_calls_ip_access_control_list_mapping` · `api20100401_auth_calls_ip_access_control_list_mapping.list_sip_auth_calls_ip_access_control_list_mapping` · `api20100401_auth_registrations_credential_list_mapping.create_sip_auth_registrations_credential_list_mapping` · `api20100401_auth_registrations_credential_list_mapping.delete_sip_auth_registrations_credential_list_mapping` · `api20100401_auth_registrations_credential_list_mapping.fetch_sip_auth_registrations_credential_list_mapping` · `api20100401_auth_registrations_credential_list_mapping.list_sip_auth_registrations_credential_list_mapping` · `api20100401_authorized_connect_app.fetch_authorized_connect_app` · `api20100401_authorized_connect_app.list_authorized_connect_app` · `api20100401_available_phone_number_country.fetch_available_phone_number_country` · `api20100401_available_phone_number_country.list_available_phone_number_country` · `api20100401_balance.fetch_balance` · `api20100401_call.create_call` · `api20100401_call.delete_call` · `api20100401_call.fetch_call` · `api20100401_call.list_call` · `api20100401_call.update_call` · `api20100401_call_notification.fetch_call_notification` · `api20100401_call_notification.list_call_notification` · `api20100401_call_recording.create_call_recording` · `api20100401_call_recording.delete_call_recording` · `api20100401_call_recording.fetch_call_recording` · `api20100401_call_recording.list_call_recording` · `api20100401_call_transcription.create_realtime_transcription` · `api20100401_call_transcription.update_realtime_transcription` · `api20100401_conference.fetch_conference` · `api20100401_conference.list_conference` · `api20100401_conference.update_conference` · `api20100401_conference_recording.delete_conference_recording` · `api20100401_conference_recording.fetch_conference_recording` · `api20100401_conference_recording.list_conference_recording` · `api20100401_conference_recording.update_conference_recording` · `api20100401_connect_app.delete_connect_app` · `api20100401_connect_app.fetch_connect_app` · `api20100401_connect_app.list_connect_app` · `api20100401_connect_app.update_connect_app` · `api20100401_credential.create_sip_credential` · `api20100401_credential.delete_sip_credential` · `api20100401_credential.fetch_sip_credential` · `api20100401_credential.list_sip_credential` · `api20100401_credential.update_sip_credential` · `api20100401_credential_list.create_sip_credential_list` · `api20100401_credential_list.delete_sip_credential_list` · `api20100401_credential_list.fetch_sip_credential_list` · `api20100401_credential_list.list_sip_credential_list` · `api20100401_credential_list.update_sip_credential_list` · `api20100401_credential_list_mapping.create_sip_credential_list_mapping` · `api20100401_credential_list_mapping.delete_sip_credential_list_mapping` · `api20100401_credential_list_mapping.fetch_sip_credential_list_mapping` · `api20100401_credential_list_mapping.list_sip_credential_list_mapping` · `api20100401_daily.list_usage_record_daily` · `api20100401_data.fetch_recording_add_on_result_payload_data` · `api20100401_dependent_phone_number.list_dependent_phone_number` · `api20100401_domain.create_sip_domain` · `api20100401_domain.delete_sip_domain` · `api20100401_domain.fetch_sip_domain` · `api20100401_domain.list_sip_domain` · `api20100401_domain.update_sip_domain` · `api20100401_event.list_call_event` · `api20100401_feedback.create_message_feedback` · `api20100401_incoming_phone_number.create_incoming_phone_number` · `api20100401_incoming_phone_number.delete_incoming_phone_number` · `api20100401_incoming_phone_number.fetch_incoming_phone_number` · `api20100401_incoming_phone_number.list_incoming_phone_number` · `api20100401_incoming_phone_number.update_incoming_phone_number` · `api20100401_incoming_phone_number_local.create_incoming_phone_number_local` · `api20100401_incoming_phone_number_local.list_incoming_phone_number_local` · `api20100401_incoming_phone_number_mobile.create_incoming_phone_number_mobile` · `api20100401_incoming_phone_number_mobile.list_incoming_phone_number_mobile` · `api20100401_incoming_phone_number_toll_free.create_incoming_phone_number_toll_free` · `api20100401_incoming_phone_number_toll_free.list_incoming_phone_number_toll_free` · `api20100401_ip_access_control_list.create_sip_ip_access_control_list` · `api20100401_ip_access_control_list.delete_sip_ip_access_control_list` · `api20100401_ip_access_control_list.fetch_sip_ip_access_control_list` · `api20100401_ip_access_control_list.list_sip_ip_access_control_list` · `api20100401_ip_access_control_list.update_sip_ip_access_control_list` · `api20100401_ip_access_control_list_mapping.create_sip_ip_access_control_list_mapping` · `api20100401_ip_access_control_list_mapping.delete_sip_ip_access_control_list_mapping` · `api20100401_ip_access_control_list_mapping.fetch_sip_ip_access_control_list_mapping` · `api20100401_ip_access_control_list_mapping.list_sip_ip_access_control_list_mapping` · `api20100401_key.delete_key` · `api20100401_key.fetch_key` · `api20100401_key.list_key` · `api20100401_key.update_key` · `api20100401_last_month.list_usage_record_last_month` · `api20100401_local.list_available_phone_number_local` · `api20100401_machine_to_machine.list_available_phone_number_machine_to_machine` · `api20100401_media.list_media` · `api20100401_media_instance.delete_media` · `api20100401_media_instance.fetch_media` · `api20100401_member.fetch_member` · `api20100401_member.list_member` · `api20100401_member.update_member` · `api20100401_message.create_message` · `api20100401_message.delete_message` · `api20100401_message.fetch_message` · `api20100401_message.list_message` · `api20100401_message.update_message` · `api20100401_mobile.list_available_phone_number_mobile` · `api20100401_monthly.list_usage_record_monthly` · `api20100401_national.list_available_phone_number_national` · `api20100401_new_key.create_new_key` · `api20100401_new_signing_key.create_new_signing_key` · `api20100401_notification.fetch_notification` · `api20100401_notification.list_notification` · `api20100401_outgoing_caller_id.delete_outgoing_caller_id` · `api20100401_outgoing_caller_id.fetch_outgoing_caller_id` · `api20100401_outgoing_caller_id.list_outgoing_caller_id` · `api20100401_outgoing_caller_id.update_outgoing_caller_id` · `api20100401_participant.create_participant` · `api20100401_participant.delete_participant` · `api20100401_participant.fetch_participant` · `api20100401_participant.list_participant` · `api20100401_participant.update_participant` · `api20100401_payload.delete_recording_add_on_result_payload` · `api20100401_payload.fetch_recording_add_on_result_payload` · `api20100401_payload.list_recording_add_on_result_payload` · `api20100401_payment.create_payments` · `api20100401_payment.update_payments` · `api20100401_queue.create_queue` · `api20100401_queue.delete_queue` · `api20100401_queue.fetch_queue` · `api20100401_queue.list_queue` · `api20100401_queue.update_queue` · `api20100401_record.list_usage_record` · `api20100401_recording.delete_recording` · `api20100401_recording.fetch_recording` · `api20100401_recording.list_recording` · `api20100401_recording_transcription.delete_recording_transcription` · `api20100401_recording_transcription.fetch_recording_transcription` · `api20100401_recording_transcription.list_recording_transcription` · `api20100401_shared_cost.list_available_phone_number_shared_cost` · `api20100401_short_code.fetch_short_code` · `api20100401_short_code.list_short_code` · `api20100401_short_code.update_short_code` · `api20100401_signing_key.delete_signing_key` · `api20100401_signing_key.fetch_signing_key` · `api20100401_signing_key.list_signing_key` · `api20100401_signing_key.update_signing_key` · `api20100401_sip_ip_address.create_sip_ip_address` · `api20100401_sip_ip_address.delete_sip_ip_address` · `api20100401_sip_ip_address.fetch_sip_ip_address` · `api20100401_sip_ip_address.list_sip_ip_address` · `api20100401_sip_ip_address.update_sip_ip_address` · `api20100401_siprec.create_siprec` · `api20100401_siprec.update_siprec` · `api20100401_stream.create_stream` · `api20100401_stream.update_stream` · `api20100401_this_month.list_usage_record_this_month` · `api20100401_today.list_usage_record_today` · `api20100401_token.create_token` · `api20100401_toll_free.list_available_phone_number_toll_free` · `api20100401_transcription.delete_transcription` · `api20100401_transcription.fetch_transcription` · `api20100401_transcription.list_transcription` · `api20100401_trigger.create_usage_trigger` · `api20100401_trigger.delete_usage_trigger` · `api20100401_trigger.fetch_usage_trigger` · `api20100401_trigger.list_usage_trigger` · `api20100401_trigger.update_usage_trigger` · `api20100401_user_defined_message.create_user_defined_message` · `api20100401_user_defined_message_subscription.create_user_defined_message_subscription` · `api20100401_user_defined_message_subscription.delete_user_defined_message_subscription` · `api20100401_validation_request.create_validation_request` · `api20100401_voip.list_available_phone_number_voip` · `api20100401_yearly.list_usage_record_yearly` · `api20100401_yesterday.list_usage_record_yesterday` · `content_v2_content.list_content2` · `content_v2_content_and_approvals.list_content_and_approvals2` · `contentv1_approval_create.create_approval_create` · `contentv1_approval_fetch.fetch_approval_fetch` · `contentv1_content_api.create_content` · `contentv1_content_api.delete_content` · `contentv1_content_api.fetch_content` · `contentv1_content_api.list_content` · `contentv1_content_api.update_content` · `contentv1_content_and_approvals_api.list_content_and_approvals` · `contentv1_legacy_content_api.list_legacy_content` · `conversations_v1_address_configuration.create_configuration_address` · `conversations_v1_address_configuration.delete_configuration_address` · `conversations_v1_address_configuration.fetch_configuration_address` · `conversations_v1_address_configuration.list_configuration_address` · `conversations_v1_address_configuration.update_configuration_address` · `conversations_v1_binding.delete_service_binding` · `conversations_v1_binding.fetch_service_binding` · `conversations_v1_binding.list_service_binding` · `conversations_v1_configuration_api.fetch_configuration` · `conversations_v1_configuration_api.fetch_service_configuration` · `conversations_v1_configuration_api.update_configuration` · `conversations_v1_configuration_api.update_service_configuration` · `conversations_v1_conversation_api.create_conversation` · `conversations_v1_conversation_api.create_service_conversation` · `conversations_v1_conversation_api.delete_conversation` · `conversations_v1_conversation_api.delete_service_conversation` · `conversations_v1_conversation_api.fetch_conversation` · `conversations_v1_conversation_api.fetch_service_conversation` · `conversations_v1_conversation_api.list_conversation` · `conversations_v1_conversation_api.list_service_conversation` · `conversations_v1_conversation_api.update_conversation` · `conversations_v1_conversation_api.update_service_conversation` · `conversations_v1_conversation_with_participants_api.create_conversation_with_participants` · `conversations_v1_conversation_with_participants_api.create_service_conversation_with_participants` · `conversations_v1_credential_api.create_credential` · `conversations_v1_credential_api.delete_credential` · `conversations_v1_credential_api.fetch_credential` · `conversations_v1_credential_api.list_credential` · `conversations_v1_credential_api.update_credential` · `conversations_v1_delivery_receipt.fetch_conversation_message_receipt` · `conversations_v1_delivery_receipt.fetch_service_conversation_message_receipt` · `conversations_v1_delivery_receipt.list_conversation_message_receipt` · `conversations_v1_delivery_receipt.list_service_conversation_message_receipt` · `conversations_v1_message.create_conversation_message` · `conversations_v1_message.create_service_conversation_message` · `conversations_v1_message.delete_conversation_message` · `conversations_v1_message.delete_service_conversation_message` · `conversations_v1_message.fetch_conversation_message` · `conversations_v1_message.fetch_service_conversation_message` · `conversations_v1_message.list_conversation_message` · `conversations_v1_message.list_service_conversation_message` · `conversations_v1_message.update_conversation_message` · `conversations_v1_message.update_service_conversation_message` · `conversations_v1_notification.fetch_service_notification` · `conversations_v1_notification.update_service_notification` · `conversations_v1_participant.create_conversation_participant` · `conversations_v1_participant.create_service_conversation_participant` · `conversations_v1_participant.delete_conversation_participant` · `conversations_v1_participant.delete_service_conversation_participant` · `conversations_v1_participant.fetch_conversation_participant` · `conversations_v1_participant.fetch_service_conversation_participant` · `conversations_v1_participant.list_conversation_participant` · `conversations_v1_participant.list_service_conversation_participant` · `conversations_v1_participant.update_conversation_participant` · `conversations_v1_participant.update_service_conversation_participant` · `conversations_v1_participant_conversation_api.list_participant_conversation` · `conversations_v1_participant_conversation_api.list_service_participant_conversation` · `conversations_v1_role_api.create_role` · `conversations_v1_role_api.create_service_role` · `conversations_v1_role_api.delete_role` · `conversations_v1_role_api.delete_service_role` · `conversations_v1_role_api.fetch_role` · `conversations_v1_role_api.fetch_service_role` · `conversations_v1_role_api.list_role` · `conversations_v1_role_api.list_service_role` · `conversations_v1_role_api.update_role` · `conversations_v1_role_api.update_service_role` · `conversations_v1_service_api.create_service3` · `conversations_v1_service_api.delete_service3` · `conversations_v1_service_api.fetch_service3` · `conversations_v1_service_api.list_service3` · `conversations_v1_user_api.create_service_user` · `conversations_v1_user_api.create_user` · `conversations_v1_user_api.delete_service_user` · `conversations_v1_user_api.delete_user` · `conversations_v1_user_api.fetch_service_user` · `conversations_v1_user_api.fetch_user` · `conversations_v1_user_api.list_service_user` · `conversations_v1_user_api.list_user` · `conversations_v1_user_api.update_service_user` · `conversations_v1_user_api.update_user` · `conversations_v1_user_conversation.delete_service_user_conversation` · `conversations_v1_user_conversation.delete_user_conversation` · `conversations_v1_user_conversation.fetch_service_user_conversation` · `conversations_v1_user_conversation.fetch_user_conversation` · `conversations_v1_user_conversation.list_service_user_conversation` · `conversations_v1_user_conversation.list_user_conversation` · `conversations_v1_user_conversation.update_service_user_conversation` · `conversations_v1_user_conversation.update_user_conversation` · `conversations_v1_webhook.create_conversation_scoped_webhook` · `conversations_v1_webhook.create_service_conversation_scoped_webhook` · `conversations_v1_webhook.delete_conversation_scoped_webhook` · `conversations_v1_webhook.delete_service_conversation_scoped_webhook` · `conversations_v1_webhook.fetch_configuration_webhook` · `conversations_v1_webhook.fetch_conversation_scoped_webhook` · `conversations_v1_webhook.fetch_service_conversation_scoped_webhook` · `conversations_v1_webhook.fetch_service_webhook_configuration` · `conversations_v1_webhook.list_conversation_scoped_webhook` · `conversations_v1_webhook.list_service_conversation_scoped_webhook` · `conversations_v1_webhook.update_configuration_webhook` · `conversations_v1_webhook.update_conversation_scoped_webhook` · `conversations_v1_webhook.update_service_conversation_scoped_webhook` · `conversations_v1_webhook.update_service_webhook_configuration` · `flex_v1_assessments.create_insights_assessments` · `flex_v1_assessments.list_insights_assessments` · `flex_v1_assessments.update_insights_assessments` · `flex_v1_channel_api.create_channel` · `flex_v1_channel_api.delete_channel` · `flex_v1_channel_api.fetch_channel` · `flex_v1_channel_api.list_channel` · `flex_v1_configuration_api.fetch_configuration3` · `flex_v1_configuration_api.update_configuration3` · `flex_v1_configured_plugin.fetch_configured_plugin` · `flex_v1_configured_plugin.list_configured_plugin` · `flex_v1_flex_flow_api.create_flex_flow` · `flex_v1_flex_flow_api.delete_flex_flow` · `flex_v1_flex_flow_api.fetch_flex_flow` · `flex_v1_flex_flow_api.list_flex_flow` · `flex_v1_flex_flow_api.update_flex_flow` · `flex_v1_insights_assessments_comment_api.create_insights_assessments_comment` · `flex_v1_insights_assessments_comment_api.list_insights_assessments_comment` · `flex_v1_insights_conversations_api.list_insights_conversations` · `flex_v1_insights_questionnaires_api.create_insights_questionnaires` · `flex_v1_insights_questionnaires_api.delete_insights_questionnaires` · `flex_v1_insights_questionnaires_api.fetch_insights_questionnaires` · `flex_v1_insights_questionnaires_api.list_insights_questionnaires` · `flex_v1_insights_questionnaires_api.update_insights_questionnaires` · `flex_v1_insights_questionnaires_category_api.create_insights_questionnaires_category` · `flex_v1_insights_questionnaires_category_api.delete_insights_questionnaires_category` · `flex_v1_insights_questionnaires_category_api.list_insights_questionnaires_category` · `flex_v1_insights_questionnaires_category_api.update_insights_questionnaires_category` · `flex_v1_insights_questionnaires_question_api.create_insights_questionnaires_question` · `flex_v1_insights_questionnaires_question_api.delete_insights_questionnaires_question` · `flex_v1_insights_questionnaires_question_api.list_insights_questionnaires_question` · `flex_v1_insights_questionnaires_question_api.update_insights_questionnaires_question` · `flex_v1_insights_segments_api.list_insights_segments` · `flex_v1_insights_session_api.create_insights_session` · `flex_v1_insights_settings_answer_sets_api.fetch_insights_settings_answersets` · `flex_v1_insights_settings_comment_api.fetch_insights_settings_comment` · `flex_v1_insights_user_roles_api.fetch_insights_user_roles` · `flex_v1_interaction_api.create_interaction` · `flex_v1_interaction_api.fetch_interaction2` · `flex_v1_interaction_api.update_interaction` · `flex_v1_interaction_channel.fetch_interaction_channel` · `flex_v1_interaction_channel.list_interaction_channel` · `flex_v1_interaction_channel.update_interaction_channel` · `flex_v1_interaction_channel_invite.create_interaction_channel_invite` · `flex_v1_interaction_channel_invite.list_interaction_channel_invite` · `flex_v1_interaction_channel_participant.create_interaction_channel_participant` · `flex_v1_interaction_channel_participant.list_interaction_channel_participant` · `flex_v1_interaction_channel_participant.update_interaction_channel_participant` · `flex_v1_interaction_transfer.create_interaction_transfer` · `flex_v1_interaction_transfer.fetch_interaction_transfer` · `flex_v1_interaction_transfer.update_interaction_transfer` · `flex_v1_plugin_api.create_plugin` · `flex_v1_plugin_api.fetch_plugin` · `flex_v1_plugin_api.list_plugin` · `flex_v1_plugin_api.update_plugin` · `flex_v1_plugin_archive_api.update_plugin_archive` · `flex_v1_plugin_configuration_api.create_plugin_configuration` · `flex_v1_plugin_configuration_api.fetch_plugin_configuration` · `flex_v1_plugin_configuration_api.list_plugin_configuration` · `flex_v1_plugin_configuration_archive_api.update_plugin_configuration_archive` · `flex_v1_plugin_release_api.create_plugin_release` · `flex_v1_plugin_release_api.fetch_plugin_release` · `flex_v1_plugin_release_api.list_plugin_release` · `flex_v1_plugin_version_archive_api.update_plugin_version_archive` · `flex_v1_plugin_versions.create_plugin_version` · `flex_v1_plugin_versions.fetch_plugin_version` · `flex_v1_plugin_versions.list_plugin_version` · `flex_v1_provisioning_status_api.fetch_provisioning_status` · `flex_v1_web_channel_api.create_web_channel` · `flex_v1_web_channel_api.delete_web_channel` · `flex_v1_web_channel_api.fetch_web_channel` · `flex_v1_web_channel_api.list_web_channel` · `flex_v1_web_channel_api.update_web_channel` · `flex_v2_flex_user_api.fetch_flex_user` · `flex_v2_flex_user_api.update_flex_user` · `flex_v2_web_channels.create_web_channel2` · `insights_v1_annotation.fetch_annotation` · `insights_v1_annotation.update_annotation` · `insights_v1_call_api.fetch_call2` · `insights_v1_call_summaries_api.list_call_summaries` · `insights_v1_call_summary_api.fetch_summary` · `insights_v1_conference_api.fetch_conference2` · `insights_v1_conference_api.list_conference2` · `insights_v1_conference_participant.fetch_conference_participant` · `insights_v1_conference_participant.list_conference_participant` · `insights_v1_create_account_report.create_account_report` · `insights_v1_create_inbound_phone_numbers_report.create_inbound_phone_numbers_report` · `insights_v1_create_outbound_phone_numbers_report.create_outbound_phone_numbers_report` · `insights_v1_event.list_event2` · `insights_v1_get_account_report.fetch_account_report` · `insights_v1_get_inbound_phone_numbers_report.list_inbound_phone_numbers_report` · `insights_v1_get_outbound_phone_numbers_report.list_outbound_phone_numbers_report` · `insights_v1_metric.list_metric` · `insights_v1_participant.fetch_video_participant_summary` · `insights_v1_participant.list_video_participant_summary` · `insights_v1_room.fetch_video_room_summary` · `insights_v1_room.list_video_room_summary` · `insights_v1_setting.fetch_account_settings` · `insights_v1_setting.update_account_settings` · `lookups_v1_phone_number_api.fetch_phone_number2` · `lookups_v2_phone_number.fetch_phone_number3` · `messaging_v1_alpha_sender.create_alpha_sender` · `messaging_v1_alpha_sender.delete_alpha_sender` · `messaging_v1_alpha_sender.fetch_alpha_sender` · `messaging_v1_alpha_sender.list_alpha_sender` · `messaging_v1_brand_registration.create_brand_registrations` · `messaging_v1_brand_registration.fetch_brand_registrations` · `messaging_v1_brand_registration.list_brand_registrations` · `messaging_v1_brand_registration.update_brand_registrations` · `messaging_v1_brand_registration_otp.create_brand_registration_otp` · `messaging_v1_brand_vetting.create_brand_vetting` · `messaging_v1_brand_vetting.fetch_brand_vetting` · `messaging_v1_brand_vetting.list_brand_vetting` · `messaging_v1_channel_sender.create_channel_sender` · `messaging_v1_channel_sender.delete_channel_sender` · `messaging_v1_channel_sender.fetch_channel_sender` · `messaging_v1_channel_sender.list_channel_sender` · `messaging_v1_deactivations.fetch_deactivation` · `messaging_v1_destination_alpha_sender.create_destination_alpha_sender` · `messaging_v1_destination_alpha_sender.delete_destination_alpha_sender` · `messaging_v1_destination_alpha_sender.fetch_destination_alpha_sender` · `messaging_v1_destination_alpha_sender.list_destination_alpha_sender` · `messaging_v1_domain_certs.delete_domain_cert_v4` · `messaging_v1_domain_certs.fetch_domain_cert_v4` · `messaging_v1_domain_certs.update_domain_cert_v4` · `messaging_v1_domain_config_api.fetch_domain_config` · `messaging_v1_domain_config_api.update_domain_config` · `messaging_v1_domain_config_messaging_service_api.fetch_domain_config_messaging_service` · `messaging_v1_domain_validate_dns.fetch_domain_dns_validation` · `messaging_v1_external_campaign_api.create_external_campaign` · `messaging_v1_linkshortening_messaging_service_api.create_linkshortening_messaging_service` · `messaging_v1_linkshortening_messaging_service_api.delete_linkshortening_messaging_service` · `messaging_v1_linkshortening_messaging_service_domain_association_api.fetch_linkshortening_messaging_service_domain_association` · `messaging_v1_phone_number.create_phone_number` · `messaging_v1_phone_number.delete_phone_number` · `messaging_v1_phone_number.fetch_phone_number` · `messaging_v1_phone_number.list_phone_number` · `messaging_v1_request_managed_cert_api.update_request_managed_cert` · `messaging_v1_service_api.create_service` · `messaging_v1_service_api.delete_service` · `messaging_v1_service_api.fetch_service` · `messaging_v1_service_api.list_service` · `messaging_v1_service_api.update_service` · `messaging_v1_short_code.create_short_code` · `messaging_v1_short_code.delete_short_code` · `messaging_v1_short_code.fetch_short_code2` · `messaging_v1_short_code.list_short_code2` · `messaging_v1_tollfree_verification_api.create_tollfree_verification` · `messaging_v1_tollfree_verification_api.delete_tollfree_verification` · `messaging_v1_tollfree_verification_api.fetch_tollfree_verification` · `messaging_v1_tollfree_verification_api.list_tollfree_verification` · `messaging_v1_tollfree_verification_api.update_tollfree_verification` · `messaging_v1_us_app_to_person.create_us_app_to_person` · `messaging_v1_us_app_to_person.delete_us_app_to_person` · `messaging_v1_us_app_to_person.fetch_us_app_to_person` · `messaging_v1_us_app_to_person.list_us_app_to_person` · `messaging_v1_us_app_to_person.update_us_app_to_person` · `messaging_v1_us_app_to_person_usecase.fetch_us_app_to_person_usecase` · `messaging_v1_usecase_api.fetch_usecase` · `messaging_v2_channels_sender.create_channels_sender` · `messaging_v2_channels_sender.delete_channels_sender` · `messaging_v2_channels_sender.fetch_channels_sender` · `messaging_v2_channels_sender.list_channels_sender` · `messaging_v2_channels_sender.update_channels_sender` · `messaging_v2_domain_certs.fetch_domain_cert_v42` · `messaging_v2_typing_indicator.create_typing_indicator` · `numbers_v1_bulk_eligibility_api.create_bulk_eligibility` · `numbers_v1_bulk_eligibility_api.fetch_bulk_eligibility` · `numbers_v1_eligibility_api.create_eligibility` · `numbers_v1_porting_port_in_api.create_porting_port_in` · `numbers_v1_porting_port_in_api.delete_porting_port_in` · `numbers_v1_porting_port_in_api.fetch_porting_port_in` · `numbers_v1_porting_port_in_api.list_port_in_requests` · `numbers_v1_porting_port_in_phone_number_api.delete_porting_port_in_phone_number` · `numbers_v1_porting_port_in_phone_number_api.fetch_porting_port_in_phone_number` · `numbers_v1_porting_portability_api.fetch_porting_portability` · `numbers_v1_porting_webhook_configuration_api.create_porting_webhook_configuration` · `numbers_v1_porting_webhook_configuration_delete_api.delete_porting_webhook_configuration_delete` · `numbers_v1_porting_webhook_configuration_fetch_api.fetch_porting_webhook_configuration_fetch` · `numbers_v1_signing_request_configuration_api.create_signing_request_configuration` · `numbers_v1_signing_request_configuration_api.list_signing_request_configuration` · `numbers_v2_authorization_document_api.create_authorization_document` · `numbers_v2_authorization_document_api.delete_authorization_document` · `numbers_v2_authorization_document_api.fetch_authorization_document` · `numbers_v2_authorization_document_api.list_authorization_document` · `numbers_v2_bulk_hosted_number_order_api.create_bulk_hosted_number_order` · `numbers_v2_bulk_hosted_number_order_api.fetch_bulk_hosted_number_order` · `numbers_v2_bundle.create_bundle` · `numbers_v2_bundle.delete_bundle` · `numbers_v2_bundle.fetch_bundle` · `numbers_v2_bundle.list_bundle` · `numbers_v2_bundle.update_bundle` · `numbers_v2_bundle_clone_api.create_bundle_clone` · `numbers_v2_bundle_copy.create_bundle_copy` · `numbers_v2_bundle_copy.list_bundle_copy` · `numbers_v2_dependent_hosted_number_order.list_dependent_hosted_number_order` · `numbers_v2_end_user.create_end_user` · `numbers_v2_end_user.delete_end_user` · `numbers_v2_end_user.fetch_end_user` · `numbers_v2_end_user.list_end_user` · `numbers_v2_end_user.update_end_user` · `numbers_v2_end_user_type.fetch_end_user_type` · `numbers_v2_end_user_type.list_end_user_type` · `numbers_v2_evaluation.create_evaluation` · `numbers_v2_evaluation.fetch_evaluation` · `numbers_v2_evaluation.list_evaluation` · `numbers_v2_hosted_number_order_api.create_hosted_number_order` · `numbers_v2_hosted_number_order_api.delete_hosted_number_order` · `numbers_v2_hosted_number_order_api.fetch_hosted_number_order` · `numbers_v2_hosted_number_order_api.list_hosted_number_order` · `numbers_v2_hosted_number_order_api.update_hosted_number_order` · `numbers_v2_item_assignment.create_item_assignment` · `numbers_v2_item_assignment.delete_item_assignment` · `numbers_v2_item_assignment.fetch_item_assignment` · `numbers_v2_item_assignment.list_item_assignment` · `numbers_v2_regulation.fetch_regulation` · `numbers_v2_regulation.list_regulation` · `numbers_v2_replace_items.create_replace_items` · `numbers_v2_supporting_document.create_supporting_document` · `numbers_v2_supporting_document.delete_supporting_document` · `numbers_v2_supporting_document.fetch_supporting_document` · `numbers_v2_supporting_document.list_supporting_document` · `numbers_v2_supporting_document.update_supporting_document` · `numbers_v2_supporting_document_type.fetch_supporting_document_type` · `numbers_v2_supporting_document_type.list_supporting_document_type` · `numbers_v3_hosted_numbers_hosted_number_order_api.create_hosted_numbers_hosted_number_order` · `proxy_v1_interaction.delete_interaction` · `proxy_v1_interaction.fetch_interaction` · `proxy_v1_interaction.list_interaction` · `proxy_v1_message_interaction.create_message_interaction` · `proxy_v1_message_interaction.fetch_message_interaction` · `proxy_v1_message_interaction.list_message_interaction` · `proxy_v1_participant.create_participant2` · `proxy_v1_participant.delete_participant2` · `proxy_v1_participant.fetch_participant3` · `proxy_v1_participant.list_participant2` · `proxy_v1_phone_number.create_phone_number2` · `proxy_v1_phone_number.delete_phone_number2` · `proxy_v1_phone_number.fetch_phone_number4` · `proxy_v1_phone_number.list_phone_number2` · `proxy_v1_phone_number.update_phone_number` · `proxy_v1_service_api.create_service4` · `proxy_v1_service_api.delete_service4` · `proxy_v1_service_api.fetch_service4` · `proxy_v1_service_api.list_service4` · `proxy_v1_service_api.update_service3` · `proxy_v1_session.create_session` · `proxy_v1_session.delete_session` · `proxy_v1_session.fetch_session` · `proxy_v1_session.list_session` · `proxy_v1_session.update_session` · `studio_v1_engagement.create_engagement` · `studio_v1_engagement.delete_engagement` · `studio_v1_engagement.fetch_engagement` · `studio_v1_engagement.list_engagement` · `studio_v1_engagement_context.fetch_engagement_context` · `studio_v1_execution.create_execution` · `studio_v1_execution.delete_execution` · `studio_v1_execution.fetch_execution` · `studio_v1_execution.list_execution` · `studio_v1_execution.update_execution` · `studio_v1_execution_context.fetch_execution_context` · `studio_v1_execution_step.fetch_execution_step` · `studio_v1_execution_step.list_execution_step` · `studio_v1_execution_step_context.fetch_execution_step_context` · `studio_v1_flow_api.delete_flow` · `studio_v1_flow_api.fetch_flow` · `studio_v1_flow_api.list_flow` · `studio_v1_step.fetch_step` · `studio_v1_step.list_step` · `studio_v1_step_context.fetch_step_context` · `studio_v2_execution.create_execution2` · `studio_v2_execution.delete_execution2` · `studio_v2_execution.fetch_execution2` · `studio_v2_execution.list_execution2` · `studio_v2_execution.update_execution2` · `studio_v2_execution_context.fetch_execution_context2` · `studio_v2_execution_step.fetch_execution_step2` · `studio_v2_execution_step.list_execution_step2` · `studio_v2_execution_step_context.fetch_execution_step_context2` · `studio_v2_flow_api.create_flow` · `studio_v2_flow_api.delete_flow2` · `studio_v2_flow_api.fetch_flow2` · `studio_v2_flow_api.list_flow2` · `studio_v2_flow_api.update_flow` · `studio_v2_flow_revision.fetch_flow_revision` · `studio_v2_flow_revision.list_flow_revision` · `studio_v2_flow_test_user_api.fetch_test_user` · `studio_v2_flow_test_user_api.update_test_user` · `studio_v2_flow_validate_api.update_flow_validate` · `sync_v1_document.create_document` · `sync_v1_document.delete_document` · `sync_v1_document.fetch_document` · `sync_v1_document.list_document` · `sync_v1_document.update_document` · `sync_v1_document_permission.delete_document_permission` · `sync_v1_document_permission.fetch_document_permission` · `sync_v1_document_permission.list_document_permission` · `sync_v1_document_permission.update_document_permission` · `sync_v1_service_api.create_service5` · `sync_v1_service_api.delete_service5` · `sync_v1_service_api.fetch_service5` · `sync_v1_service_api.list_service5` · `sync_v1_service_api.update_service4` · `sync_v1_stream_message.create_stream_message` · `sync_v1_sync_list.create_sync_list` · `sync_v1_sync_list.delete_sync_list` · `sync_v1_sync_list.fetch_sync_list` · `sync_v1_sync_list.list_sync_list` · `sync_v1_sync_list.update_sync_list` · `sync_v1_sync_list_item.create_sync_list_item` · `sync_v1_sync_list_item.delete_sync_list_item` · `sync_v1_sync_list_item.fetch_sync_list_item` · `sync_v1_sync_list_item.list_sync_list_item` · `sync_v1_sync_list_item.update_sync_list_item` · `sync_v1_sync_list_permission.delete_sync_list_permission` · `sync_v1_sync_list_permission.fetch_sync_list_permission` · `sync_v1_sync_list_permission.list_sync_list_permission` · `sync_v1_sync_list_permission.update_sync_list_permission` · `sync_v1_sync_map.create_sync_map` · `sync_v1_sync_map.delete_sync_map` · `sync_v1_sync_map.fetch_sync_map` · `sync_v1_sync_map.list_sync_map` · `sync_v1_sync_map.update_sync_map` · `sync_v1_sync_map_item.create_sync_map_item` · `sync_v1_sync_map_item.delete_sync_map_item` · `sync_v1_sync_map_item.fetch_sync_map_item` · `sync_v1_sync_map_item.list_sync_map_item` · `sync_v1_sync_map_item.update_sync_map_item` · `sync_v1_sync_map_permission.delete_sync_map_permission` · `sync_v1_sync_map_permission.fetch_sync_map_permission` · `sync_v1_sync_map_permission.list_sync_map_permission` · `sync_v1_sync_map_permission.update_sync_map_permission` · `sync_v1_sync_stream.create_sync_stream` · `sync_v1_sync_stream.delete_sync_stream` · `sync_v1_sync_stream.fetch_sync_stream` · `sync_v1_sync_stream.list_sync_stream` · `sync_v1_sync_stream.update_sync_stream` · `taskrouter_v1_activity.create_activity` · `taskrouter_v1_activity.delete_activity` · `taskrouter_v1_activity.fetch_activity` · `taskrouter_v1_activity.list_activity` · `taskrouter_v1_activity.update_activity` · `taskrouter_v1_event.fetch_event` · `taskrouter_v1_event.list_event` · `taskrouter_v1_task.create_task` · `taskrouter_v1_task.delete_task` · `taskrouter_v1_task.fetch_task` · `taskrouter_v1_task.list_task` · `taskrouter_v1_task.update_task` · `taskrouter_v1_task_channel.create_task_channel` · `taskrouter_v1_task_channel.delete_task_channel` · `taskrouter_v1_task_channel.fetch_task_channel` · `taskrouter_v1_task_channel.list_task_channel` · `taskrouter_v1_task_channel.update_task_channel` · `taskrouter_v1_task_queue.create_task_queue` · `taskrouter_v1_task_queue.delete_task_queue` · `taskrouter_v1_task_queue.fetch_task_queue` · `taskrouter_v1_task_queue.list_task_queue` · `taskrouter_v1_task_queue.update_task_queue` · `taskrouter_v1_task_queue_bulk_real_time_statistics.create_task_queue_bulk_real_time_statistics` · `taskrouter_v1_task_queue_cumulative_statistics.fetch_task_queue_cumulative_statistics` · `taskrouter_v1_task_queue_real_time_statistics.fetch_task_queue_real_time_statistics` · `taskrouter_v1_task_queue_statistics.fetch_task_queue_statistics` · `taskrouter_v1_task_queues_statistics.list_task_queues_statistics` · `taskrouter_v1_task_reservation.fetch_task_reservation` · `taskrouter_v1_task_reservation.list_task_reservation` · `taskrouter_v1_task_reservation.update_task_reservation` · `taskrouter_v1_worker.create_worker` · `taskrouter_v1_worker.delete_worker` · `taskrouter_v1_worker.fetch_worker` · `taskrouter_v1_worker.list_worker` · `taskrouter_v1_worker.update_worker` · `taskrouter_v1_worker_channel.fetch_worker_channel` · `taskrouter_v1_worker_channel.list_worker_channel` · `taskrouter_v1_worker_channel.update_worker_channel` · `taskrouter_v1_worker_reservation.fetch_worker_reservation` · `taskrouter_v1_worker_reservation.list_worker_reservation` · `taskrouter_v1_worker_reservation.update_worker_reservation` · `taskrouter_v1_worker_statistics.fetch_worker_instance_statistics` · `taskrouter_v1_workers_cumulative_statistics.fetch_workers_cumulative_statistics` · `taskrouter_v1_workers_real_time_statistics.fetch_workers_real_time_statistics` · `taskrouter_v1_workers_statistics.fetch_worker_statistics` · `taskrouter_v1_workflow.create_workflow` · `taskrouter_v1_workflow.delete_workflow` · `taskrouter_v1_workflow.fetch_workflow` · `taskrouter_v1_workflow.list_workflow` · `taskrouter_v1_workflow.update_workflow` · `taskrouter_v1_workflow_cumulative_statistics.fetch_workflow_cumulative_statistics` · `taskrouter_v1_workflow_real_time_statistics.fetch_workflow_real_time_statistics` · `taskrouter_v1_workflow_statistics.fetch_workflow_statistics` · `taskrouter_v1_workspace_api.create_workspace` · `taskrouter_v1_workspace_api.delete_workspace` · `taskrouter_v1_workspace_api.fetch_workspace` · `taskrouter_v1_workspace_api.list_workspace` · `taskrouter_v1_workspace_api.update_workspace` · `taskrouter_v1_workspace_cumulative_statistics.fetch_workspace_cumulative_statistics` · `taskrouter_v1_workspace_real_time_statistics.fetch_workspace_real_time_statistics` · `taskrouter_v1_workspace_statistics.fetch_workspace_statistics` · `trusthub_v1_compliance_inquiries.create_compliance_inquiry` · `trusthub_v1_compliance_inquiries.update_compliance_inquiry` · `trusthub_v1_compliance_registration_inquiries.create_compliance_registration` · `trusthub_v1_compliance_registration_inquiries.update_compliance_registration` · `trusthub_v1_compliance_tollfree_inquiries.create_compliance_tollfree_inquiry` · `trusthub_v1_customer_profiles.create_customer_profile` · `trusthub_v1_customer_profiles.delete_customer_profile` · `trusthub_v1_customer_profiles.fetch_customer_profile` · `trusthub_v1_customer_profiles.list_customer_profile` · `trusthub_v1_customer_profiles.update_customer_profile` · `trusthub_v1_customer_profiles_channel_endpoint_assignment.create_customer_profile_channel_endpoint_assignment` · `trusthub_v1_customer_profiles_channel_endpoint_assignment.delete_customer_profile_channel_endpoint_assignment` · `trusthub_v1_customer_profiles_channel_endpoint_assignment.fetch_customer_profile_channel_endpoint_assignment` · `trusthub_v1_customer_profiles_channel_endpoint_assignment.list_customer_profile_channel_endpoint_assignment` · `trusthub_v1_customer_profiles_entity_assignments.create_customer_profile_entity_assignment` · `trusthub_v1_customer_profiles_entity_assignments.delete_customer_profile_entity_assignment` · `trusthub_v1_customer_profiles_entity_assignments.fetch_customer_profile_entity_assignment` · `trusthub_v1_customer_profiles_entity_assignments.list_customer_profile_entity_assignment` · `trusthub_v1_customer_profiles_evaluations.create_customer_profile_evaluation` · `trusthub_v1_customer_profiles_evaluations.fetch_customer_profile_evaluation` · `trusthub_v1_customer_profiles_evaluations.list_customer_profile_evaluation` · `trusthub_v1_end_user_api.create_end_user2` · `trusthub_v1_end_user_api.delete_end_user2` · `trusthub_v1_end_user_api.fetch_end_user2` · `trusthub_v1_end_user_api.list_end_user2` · `trusthub_v1_end_user_api.update_end_user2` · `trusthub_v1_end_user_type.fetch_end_user_type2` · `trusthub_v1_end_user_type.list_end_user_type2` · `trusthub_v1_policies_api.fetch_policies` · `trusthub_v1_policies_api.list_policies` · `trusthub_v1_supporting_document_api.create_supporting_document2` · `trusthub_v1_supporting_document_api.delete_supporting_document2` · `trusthub_v1_supporting_document_api.fetch_supporting_document2` · `trusthub_v1_supporting_document_api.list_supporting_document2` · `trusthub_v1_supporting_document_api.update_supporting_document2` · `trusthub_v1_supporting_document_type.fetch_supporting_document_type2` · `trusthub_v1_supporting_document_type.list_supporting_document_type2` · `trusthub_v1_trust_products.create_trust_product` · `trusthub_v1_trust_products.delete_trust_product` · `trusthub_v1_trust_products.fetch_trust_product` · `trusthub_v1_trust_products.list_trust_product` · `trusthub_v1_trust_products.update_trust_product` · `trusthub_v1_trust_products_channel_endpoint_assignment.create_trust_product_channel_endpoint_assignment` · `trusthub_v1_trust_products_channel_endpoint_assignment.delete_trust_product_channel_endpoint_assignment` · `trusthub_v1_trust_products_channel_endpoint_assignment.fetch_trust_product_channel_endpoint_assignment` · `trusthub_v1_trust_products_channel_endpoint_assignment.list_trust_product_channel_endpoint_assignment` · `trusthub_v1_trust_products_entity_assignments.create_trust_product_entity_assignment` · `trusthub_v1_trust_products_entity_assignments.delete_trust_product_entity_assignment` · `trusthub_v1_trust_products_entity_assignments.fetch_trust_product_entity_assignment` · `trusthub_v1_trust_products_entity_assignments.list_trust_product_entity_assignment` · `trusthub_v1_trust_products_evaluations.create_trust_product_evaluation` · `trusthub_v1_trust_products_evaluations.fetch_trust_product_evaluation` · `trusthub_v1_trust_products_evaluations.list_trust_product_evaluation` · `v2_short_code_applications.create_short_code_application` · `v2_short_code_applications.fetch_short_code_application` · `v2_short_code_applications.list_short_code_applications` · `verify_v2_access_token.create_access_token` · `verify_v2_access_token.fetch_access_token` · `verify_v2_bucket.create_bucket` · `verify_v2_bucket.delete_bucket` · `verify_v2_bucket.fetch_bucket` · `verify_v2_bucket.list_bucket` · `verify_v2_bucket.update_bucket` · `verify_v2_challenge.create_challenge` · `verify_v2_challenge.fetch_challenge` · `verify_v2_challenge.list_challenge` · `verify_v2_challenge.update_challenge` · `verify_v2_entity.create_entity` · `verify_v2_entity.delete_entity` · `verify_v2_entity.fetch_entity` · `verify_v2_entity.list_entity` · `verify_v2_factor.delete_factor` · `verify_v2_factor.fetch_factor` · `verify_v2_factor.list_factor` · `verify_v2_factor.update_factor` · `verify_v2_form_api.fetch_form` · `verify_v2_messaging_configuration.create_messaging_configuration` · `verify_v2_messaging_configuration.delete_messaging_configuration` · `verify_v2_messaging_configuration.fetch_messaging_configuration` · `verify_v2_messaging_configuration.list_messaging_configuration` · `verify_v2_messaging_configuration.update_messaging_configuration` · `verify_v2_new_challenge.create_challenge_passkeys` · `verify_v2_new_factor.create_new_factor` · `verify_v2_new_factor.create_new_factor_passkey` · `verify_v2_notification.create_notification` · `verify_v2_rate_limit.create_rate_limit` · `verify_v2_rate_limit.delete_rate_limit` · `verify_v2_rate_limit.fetch_rate_limit` · `verify_v2_rate_limit.list_rate_limit` · `verify_v2_rate_limit.update_rate_limit` · `verify_v2_safelist_api.create_safelist` · `verify_v2_safelist_api.delete_safelist` · `verify_v2_safelist_api.fetch_safelist` · `verify_v2_service_api.create_service2` · `verify_v2_service_api.delete_service2` · `verify_v2_service_api.fetch_service2` · `verify_v2_service_api.list_service2` · `verify_v2_service_api.update_service2` · `verify_v2_template.list_verification_template` · `verify_v2_verification.fetch_verification` · `verify_v2_verification.update_verification` · `verify_v2_verification_attempt_api.fetch_verification_attempt` · `verify_v2_verification_attempt_api.list_verification_attempt` · `verify_v2_verification_attempts_summary_api.fetch_verification_attempts_summary` · `verify_v2_verification_check.create_verification_check` · `verify_v2_webhook.create_webhook` · `verify_v2_webhook.delete_webhook` · `verify_v2_webhook.fetch_webhook` · `verify_v2_webhook.list_webhook` · `verify_v2_webhook.update_webhook` · `video_v1_anonymize.update_room_participant_anonymize` · `video_v1_composition_api.create_composition` · `video_v1_composition_api.delete_composition` · `video_v1_composition_api.fetch_composition` · `video_v1_composition_api.list_composition` · `video_v1_composition_hook_api.create_composition_hook` · `video_v1_composition_hook_api.delete_composition_hook` · `video_v1_composition_hook_api.fetch_composition_hook` · `video_v1_composition_hook_api.list_composition_hook` · `video_v1_composition_hook_api.update_composition_hook` · `video_v1_composition_settings_api.create_composition_settings` · `video_v1_composition_settings_api.fetch_composition_settings` · `video_v1_participant.fetch_room_participant` · `video_v1_participant.list_room_participant` · `video_v1_participant.update_room_participant` · `video_v1_published_track.fetch_room_participant_published_track` · `video_v1_published_track.list_room_participant_published_track` · `video_v1_recording_api.delete_recording2` · `video_v1_recording_api.fetch_recording2` · `video_v1_recording_api.list_recording2` · `video_v1_recording_rules.fetch_room_recording_rule` · `video_v1_recording_rules.update_room_recording_rule` · `video_v1_recording_settings_api.create_recording_settings` · `video_v1_recording_settings_api.fetch_recording_settings` · `video_v1_room_api.create_room` · `video_v1_room_api.fetch_room` · `video_v1_room_api.list_room` · `video_v1_room_api.update_room` · `video_v1_room_recording.delete_room_recording` · `video_v1_room_recording.fetch_room_recording` · `video_v1_room_recording.list_room_recording` · `video_v1_subscribe_rules.fetch_room_participant_subscribe_rule` · `video_v1_subscribe_rules.update_room_participant_subscribe_rule` · `video_v1_subscribed_track.fetch_room_participant_subscribed_track` · `video_v1_subscribed_track.list_room_participant_subscribed_track` · `video_v1_transcriptions.create_room_transcriptions` · `video_v1_transcriptions.fetch_room_transcriptions` · `video_v1_transcriptions.list_room_transcriptions` · `video_v1_transcriptions.update_room_transcriptions`: no typed arm, so `.error` is always `RawError` 9. So `isinstance(e.error, AccountsCallsRecordingsSidJson201041408Error1)` matches only 34 of 898 operations. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 23 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which server each call resolves against**, set through `server_config`. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 23 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which server each call resolves against**, set through `server_config`. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/twilio/skills/python/python-integrate-twilio/SKILL.md b/plugins/twilio/skills/python/python-integrate-twilio-sdk/SKILL.md similarity index 81% rename from plugins/twilio/skills/python/python-integrate-twilio/SKILL.md rename to plugins/twilio/skills/python/python-integrate-twilio-sdk/SKILL.md index dedaf7a15..0fda96dce 100644 --- a/plugins/twilio/skills/python/python-integrate-twilio/SKILL.md +++ b/plugins/twilio/skills/python/python-integrate-twilio-sdk/SKILL.md @@ -1,81 +1,97 @@ ---- -name: "python-integrate-twilio" -description: "MANDATORY FIRST STEP for Twilio SDK Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Twilio SDK in Python — approve a passkeys challenge, verify a passkeys factor, modify the properties of a given account, fetch an instance of an addonresult, usage records for all time, create a new application within your account, updates the application's properties, create a new credential list mapping resource — or when a Twilio SDK Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." ---- - -# Twilio SDK Python SDK — Integration workflow (lookup layer + contract sheet) - -You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. - -## Your lookup layer - -- **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. -- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import twilio_sdk, pathlib; print(pathlib.Path(twilio_sdk.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) -- **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. -- **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. - -**Scope guard:** the APIMatic-generated Twilio SDK **Python SDK** (import root `twilio_sdk`, distribution `twilio-sdk`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. - -## Workflow - -**If the user opens with a reported SDK error or unexpected Twilio SDK behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: - -### Step 1 — Plan first (always, for any implementation work) - -Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/twilio-sdk-plan.md` — that absolute path, not a location you pick later. - -**Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - -- the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `twilio-sdk` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; -- a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — including which host `server_config` will select, since omitting it is silent; -- **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; -- setting up your task tracking. - -Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. - -**HARD GATE — no project-file creation or edits until:** `twilio-sdk-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. - -**What the gate does not cover:** creating or activating a virtual environment, installing `twilio-sdk` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. - -### Step 1c — Required reading (do this before you write any code) - -End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. - -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `python-error-handling` always (every integration writes an error boundary); `python-client-initialization` before the client is constructed; `python-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes the transport. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. - -These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a module in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. - -Before implementing, check the plan's **Assumptions & Blockers** section: - -- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `twilio-sdk-plan.md` in place. -- Minor assumptions only → proceed. - -Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. - -### Step 2 — Implement from the contract sheet - -1. Read `twilio-sdk-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). -3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. -4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. -5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `twilio_sdk.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. - -### Step 3 — Answering pure questions - -A standalone Twilio SDK question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. - -## Anti-patterns — never do these - -- **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. -- **Never write a Twilio SDK/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Don't web-search Twilio SDK topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Twilio SDK docs describe the REST API, not this SDK's generated surface, and the two disagree on names. -- **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. -- **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. -- **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. -- **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. - +--- +name: "python-integrate-twilio-sdk" +description: "MANDATORY FIRST STEP for Twilio SDK Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Twilio SDK in Python — approve a passkeys challenge, verify a passkeys factor, modify the properties of a given account, fetch an instance of an addonresult, usage records for all time, create a new application within your account, updates the application's properties, create a new credential list mapping resource — or when a Twilio SDK Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." +--- + +# Twilio SDK Python SDK — Integration workflow (lookup layer + contract sheet) + +You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. + +## Your lookup layer + +- **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. +- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import twilio_sdk, pathlib; print(pathlib.Path(twilio_sdk.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. +- **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. + +**Scope guard:** the APIMatic-generated Twilio SDK **Python SDK** (import root `twilio_sdk`, distribution `twilio-sdk`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. + +## Workflow + +**If the user opens with a reported SDK error or unexpected Twilio SDK behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: + +### Step 1 — Plan first (always, for any implementation work) + +Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/twilio-sdk-plan.md` — that absolute path, not a location you pick later. + +**Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** + +- the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; +- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `twilio-sdk` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; +- a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; +- credentials/environment verification (per the task's secret-handling rules) — including which host `server_config` will select, since omitting it is silent; +- **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; +- setting up your task tracking. + +Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. + +**HARD GATE — no project-file creation or edits until:** `twilio-sdk-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. + +**What the gate does not cover:** creating or activating a virtual environment, installing `twilio-sdk` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +### Step 1c — Required reading (do this before you write any code) + +End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. + +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `python-error-handling` always (every integration writes an error boundary); `python-client-initialization` before the client is constructed; `python-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes the transport. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. + +These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a module in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. + +Before implementing, check the plan's **Assumptions & Blockers** section: + +- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `twilio-sdk-plan.md` in place. +- Minor assumptions only → proceed. + +Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. + +### Step 2 — Implement from the contract sheet + +1. Read `twilio-sdk-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. +2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). +3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. +4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. +5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `twilio_sdk.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. + +### Step 3 — Answering pure questions + +A standalone Twilio SDK question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. + +## Anti-patterns — never do these + +- **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. +- **Never write a Twilio SDK/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** +- **Don't web-search Twilio SDK topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Twilio SDK docs describe the REST API, not this SDK's generated surface, and the two disagree on names. +- **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. +- **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. +- **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. +- **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. + diff --git a/plugins/twilio/skills/python/python-models/SKILL.md b/plugins/twilio/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/twilio/skills/python/python-models/SKILL.md +++ b/plugins/twilio/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/twilio/skills/python/python-models/reference.md b/plugins/twilio/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/twilio/skills/python/python-models/reference.md +++ b/plugins/twilio/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/twilio/skills/python/python-testing/SKILL.md b/plugins/twilio/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/twilio/skills/python/python-testing/SKILL.md +++ b/plugins/twilio/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/twilio/skills/typescript/typescript-authentication/SKILL.md b/plugins/twilio/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/twilio/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/twilio/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/twilio/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/twilio/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/twilio/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/twilio/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/twilio/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/twilio/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/twilio/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/twilio/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/twilio/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/twilio/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/twilio/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/twilio/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/twilio/skills/typescript/typescript-error-handling/SKILL.md b/plugins/twilio/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/twilio/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/twilio/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/twilio/skills/typescript/typescript-getting-started/SKILL.md b/plugins/twilio/skills/typescript/typescript-getting-started/SKILL.md index 083aa843e..f0cf91637 100644 --- a/plugins/twilio/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/twilio/skills/typescript/typescript-getting-started/SKILL.md @@ -5,7 +5,7 @@ description: "Twilio SDK TypeScript SDK identity and lookup layer (TypeScript/Ja # Getting started with the Twilio SDK TypeScript SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Twilio SDK TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-twilio` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Twilio SDK TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-twilio-sdk` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated TypeScript SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `typescript-client-initialization`, `typescript-authentication`, `typescript-calling-endpoints`, `typescript-models`, `typescript-error-handling`, `typescript-configuration-resilience` and `typescript-testing`. @@ -20,15 +20,17 @@ Verified against `package.json` and `sdk-map.md` of the generated package at ver | API | Twilio SDK | | Package name (what you install, and what you import) | `twilio-sdk` — **not on npm**; built from source (see *Install*) | | Import specifier | `twilio-sdk` — the package root is the **only** entry; deep imports do not resolve | +| Source repository | https://github.com/context-plugins/twilio-typescript-sdk (branch `main` — the ref this map documents) | | Version | `1.0.0` (API spec version `1.0.0`) | | Client class | `TwilioSdkClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new TwilioSdkClient(clientOptions: Partial = {})` — **every** field is optional, so `new TwilioSdkClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `accountSidAuthToken`. `timeout` defaults to `60_000` ms | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new TwilioSdkClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new TwilioSdkClient()` compiles. Fields: `serverOptions` · `retry` · `fetch` · `accountSidAuthToken`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **HTTP Basic** — set `ClientOptions.accountSidAuthToken` | -| Environments | 1 environment (`ServerEnvironment.Production` *(default)*) × 15 server groups | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`) | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Environments | no selector — one environment, so none is emitted; 15 server groups | +| Base-URL config | `serverOptions..baseUrl` (`src/servers.ts`) | +| Log environment variable | `TWILIO_SDK_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Surface | 898 operations · 318 resources · 838 models · 399 open enums · 4 unions · 39 per-operation error subclasses | @@ -37,22 +39,22 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/twilio-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 1241 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, 1241 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { TwilioSdkClient, ServerEnvironment, ResponseError, TwilioSdkError } from "twilio-sdk"; +import { TwilioSdkClient, ApiError, TwilioSdkError } from "twilio-sdk"; import type { ClientOptions, AccountType } from "twilio-sdk"; ``` @@ -67,35 +69,33 @@ Things the specifier alone will not tell you: Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, every model type) must be imported with `import type`. Under `exactOptionalPropertyTypes`, **omit or spread** an absent optional field rather than assigning `undefined` to it. -## Environments +## Hosts -`ClientOptions.serverEnvironment` selects one environment for the whole client (`src/servers.ts`). `ServerEnvironment` is a `const` object with a derived union type — not a TypeScript `enum` — and unlike the model enums it is **closed**, so only its declared members are assignable. +This API declares one environment, so the SDK has **no environment selector** — there is no `ServerEnvironment` type and no field to set (`src/servers.ts`). The host below is the only one the SDK knows, and the override in the last column is the only way to reach another. -| Group | Environment | Base URL | Override at | -| --- | --- | --- | --- | -| `default` | `production` *(default)* | `https://api.twilio.com` | `serverOptions.default.production.baseUrl` | -| `default1` | `production` *(default)* | `https://messaging.twilio.com` | `serverOptions.default1.production.baseUrl` | -| `default2` | `production` *(default)* | `https://content.twilio.com` | `serverOptions.default2.production.baseUrl` | -| `default3` | `production` *(default)* | `https://verify.twilio.com` | `serverOptions.default3.production.baseUrl` | -| `default4` | `production` *(default)* | `https://lookups.twilio.com` | `serverOptions.default4.production.baseUrl` | -| `default5` | `production` *(default)* | `https://numbers.twilio.com` | `serverOptions.default5.production.baseUrl` | -| `default6` | `production` *(default)* | `https://video.twilio.com` | `serverOptions.default6.production.baseUrl` | -| `default7` | `production` *(default)* | `https://conversations.twilio.com` | `serverOptions.default7.production.baseUrl` | -| `default8` | `production` *(default)* | `https://taskrouter.twilio.com` | `serverOptions.default8.production.baseUrl` | -| `default9` | `production` *(default)* | `https://trusthub.twilio.com` | `serverOptions.default9.production.baseUrl` | -| `default10` | `production` *(default)* | `https://proxy.twilio.com` | `serverOptions.default10.production.baseUrl` | -| `default11` | `production` *(default)* | `https://studio.twilio.com` | `serverOptions.default11.production.baseUrl` | -| `default12` | `production` *(default)* | `https://sync.twilio.com` | `serverOptions.default12.production.baseUrl` | -| `default13` | `production` *(default)* | `https://flex-api.twilio.com` | `serverOptions.default13.production.baseUrl` | -| `default14` | `production` *(default)* | `https://insights.twilio.com` | `serverOptions.default14.production.baseUrl` | +| Group | Base URL | Override at | +| --- | --- | --- | +| `default` | `https://api.twilio.com` | `serverOptions.default.baseUrl` | +| `default1` | `https://messaging.twilio.com` | `serverOptions.default1.baseUrl` | +| `default2` | `https://content.twilio.com` | `serverOptions.default2.baseUrl` | +| `default3` | `https://verify.twilio.com` | `serverOptions.default3.baseUrl` | +| `default4` | `https://lookups.twilio.com` | `serverOptions.default4.baseUrl` | +| `default5` | `https://numbers.twilio.com` | `serverOptions.default5.baseUrl` | +| `default6` | `https://video.twilio.com` | `serverOptions.default6.baseUrl` | +| `default7` | `https://conversations.twilio.com` | `serverOptions.default7.baseUrl` | +| `default8` | `https://taskrouter.twilio.com` | `serverOptions.default8.baseUrl` | +| `default9` | `https://trusthub.twilio.com` | `serverOptions.default9.baseUrl` | +| `default10` | `https://proxy.twilio.com` | `serverOptions.default10.baseUrl` | +| `default11` | `https://studio.twilio.com` | `serverOptions.default11.baseUrl` | +| `default12` | `https://sync.twilio.com` | `serverOptions.default12.baseUrl` | +| `default13` | `https://flex-api.twilio.com` | `serverOptions.default13.baseUrl` | +| `default14` | `https://insights.twilio.com` | `serverOptions.default14.baseUrl` | Consequences to state on every contract sheet that touches configuration: -- Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- ⚠ **This SDK declares exactly one environment**, so reaching any other host is a `serverOptions` override, not an environment value — `serverOptions: { default: { production: { baseUrl: "…" } } }`. Note the key is still `production`: the environment name and the host it points at are now decoupled, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the deployment's intent explicit where the client is built, and verify it. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- ⚠ **This SDK declares exactly one environment**, so it emits no `ServerEnvironment` at all and reaching any other host is a `serverOptions` base-URL override — `serverOptions: { default: { baseUrl: "…" } }`. Nothing in the options then records **which** deployment this is, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the intent explicit where the client is built, and verify it. +- An override merges with the built-in default **key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. ## Auth pattern (1 scheme) @@ -117,7 +117,7 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. See `typescript-authentication` for the full picture. @@ -448,7 +448,7 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c 11 operations are methods on the client itself: `client.createBulkLookup` · `client.createLookupPhoneNumberOverrides` · `client.deleteLookupPhoneNumberOverrides` · `client.deleteLookupRateLimit` · `client.fetchLookupAccountRateLimits` · `client.fetchLookupPhoneNumberOverrides` · `client.fetchLookupRateLimit` · `client.updateChallengePasskeys` · `client.updateLookupPhoneNumberOverrides` · `client.updateLookupRateLimit` · `client.updatePasskeysFactor`. -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** 2 operations take `RequestParams` instead: `createShortCodeApplication`, `createHostedNumbersHostedNumberOrder`. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -456,7 +456,7 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: @@ -465,11 +465,11 @@ Locate the installed package before you rely on a lookup: node -e "console.log(require.resolve('twilio-sdk/package.json'))" ``` -Failing that it is at `node_modules/twilio-sdk/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/twilio-sdk/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/twilio-sdk/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -477,7 +477,7 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/twilio-sdk/`: @@ -487,13 +487,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -507,30 +509,38 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `TWILIO_SDK_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new TwilioSdkClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new TwilioSdkClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) 2. **Authentication** — load **typescript-authentication** before you set credentials. The scheme is `accountSidAuthToken` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **128 operations resolve to `undefined`**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `TwilioSdkError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `TwilioSdkError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `TWILIO_SDK_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK Beyond the usual signatures and model members, a contract sheet for the Twilio SDK TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. -1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment, `ServerEnvironment.Production`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. +1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment and emits no `ServerEnvironment`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. 2. **128 operations resolve to `undefined`** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet — 2 operations take `RequestParams`, not `Request`. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 39 of 898 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. **That `Error`, `Event`, `Response` imported from this package are model types, not the globals of those names** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `TwilioSdkError` for the same reason. -10. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 39 of 898 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. **That `Error`, `Event`, `Response` imported from this package are model types, not the globals of those names** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `TwilioSdkError` for the same reason. +12. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/twilio/skills/typescript/typescript-integrate-twilio/SKILL.md b/plugins/twilio/skills/typescript/typescript-integrate-twilio-sdk/SKILL.md similarity index 51% rename from plugins/twilio/skills/typescript/typescript-integrate-twilio/SKILL.md rename to plugins/twilio/skills/typescript/typescript-integrate-twilio-sdk/SKILL.md index 9a37288fd..9e964f7de 100644 --- a/plugins/twilio/skills/typescript/typescript-integrate-twilio/SKILL.md +++ b/plugins/twilio/skills/typescript/typescript-integrate-twilio-sdk/SKILL.md @@ -1,6 +1,6 @@ --- -name: "typescript-integrate-twilio" -description: "MANDATORY FIRST STEP for Twilio SDK TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Twilio SDK in TypeScript/Node — create bulk lookup, create lookup phone number overrides, delete lookup phone number overrides, delete lookup rate limit, fetch lookup account rate limits, fetch lookup phone number overrides, or when the Twilio SDK TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `twilio-sdk` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (twilio-sdk-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares only ServerEnvironment.Production, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." +name: "typescript-integrate-twilio-sdk" +description: "MANDATORY FIRST STEP for Twilio SDK TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Twilio SDK in TypeScript/Node — create bulk lookup, create lookup phone number overrides, delete lookup phone number overrides, delete lookup rate limit, fetch lookup account rate limits, fetch lookup phone number overrides, or when the Twilio SDK TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `twilio-sdk` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (twilio-sdk-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares one environment and no ServerEnvironment, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." --- # Twilio SDK TypeScript SDK — Integration workflow (lookup layer + contract sheet) @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/twilio-sdk/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('twilio-sdk/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('twilio-sdk/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. @@ -23,14 +23,14 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/twilio-sdk-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/twilio-sdk-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, **module format — ESM or CJS, which changes how the SDK is imported and whether `instanceof` on its error classes is trustworthy**, and the runtime: Node, a bundled browser app, or an edge runtime) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - **establish the toolchain before you need it**: the package manager, whether `twilio-sdk` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; - a baseline run of the project's checks (`tsc --noEmit`, the test runner, the linter — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment (`ServerEnvironment.Production`), so **any other host is a `serverOptions` base-URL override rather than an environment value**; +- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment, so it emits no `ServerEnvironment` at all and **any other host is a `serverOptions` base-URL override rather than an environment value**; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your credential may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; - setting up your task tracking. @@ -40,11 +40,85 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** installing `twilio-sdk` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. @@ -58,13 +132,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp ### Step 2 — Implement from the contract sheet 1. Read `twilio-sdk-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment — `ServerEnvironment.Production` — so a different host is a `serverOptions` override under a key still named after `production`: the environment name and the host it points at are decoupled, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. +2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment and emits no selector for it, so a different host is a `serverOptions` base-URL override. Nothing in the options then records **which** deployment this is, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. 3. **Build the client once and hold it.** Resources are memoized lazy getters on it, so rebuilding one per request throws that state away. There is no `close()` — it owns a `fetch`, not a pool. 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from the **128 operations that resolve to `undefined`** (every operation whose success declares no body — the map's **Returns** bullet names them). `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `TwilioSdkError` family, told apart by `err.kind`. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `TWILIO_SDK_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions @@ -74,7 +150,7 @@ A standalone Twilio SDK question with no code change: look it up in `typescript- - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. - **Never write any Twilio SDK or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. - **Don't web-search Twilio SDK topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Twilio SDK docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. diff --git a/plugins/twilio/skills/typescript/typescript-models/SKILL.md b/plugins/twilio/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/twilio/skills/typescript/typescript-models/SKILL.md +++ b/plugins/twilio/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/twilio/skills/typescript/typescript-models/reference.md b/plugins/twilio/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/twilio/skills/typescript/typescript-models/reference.md +++ b/plugins/twilio/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/twilio/skills/typescript/typescript-testing/SKILL.md b/plugins/twilio/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/twilio/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/twilio/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/verizon/.claude-plugin/plugin.json b/plugins/verizon/.claude-plugin/plugin.json index ad8cf655b..0a8fead22 100644 --- a/plugins/verizon/.claude-plugin/plugin.json +++ b/plugins/verizon/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "verizon", "displayName": "Verizon", - "version": "0.2.1", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Verizon SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/verizon/.cursor-plugin/plugin.json b/plugins/verizon/.cursor-plugin/plugin.json index ad8cf655b..0a8fead22 100644 --- a/plugins/verizon/.cursor-plugin/plugin.json +++ b/plugins/verizon/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "verizon", "displayName": "Verizon", - "version": "0.2.1", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Verizon SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/verizon/plugin.json b/plugins/verizon/plugin.json index ad8cf655b..0a8fead22 100644 --- a/plugins/verizon/plugin.json +++ b/plugins/verizon/plugin.json @@ -1,7 +1,7 @@ { "name": "verizon", "displayName": "Verizon", - "version": "0.2.1", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Verizon SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/verizon/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/verizon/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..ba8124b05 100644 --- a/plugins/verizon/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/verizon/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,67 +1,58 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Verizon .NET SDK in C# — this API authenticates with an API key in a header and OAuth 2.0 client credentials, and this skill gives the credential each scheme takes, the VerizonClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Verizon .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with an API key in a header and OAuth 2.0 client credentials. APIMatic surfaces each scheme as a **nullable credentials property** on `VerizonClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) - -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). - -## Basic auth +| Scheme | Property on `VerizonClientOptions` | Credential | +| --- | --- | --- | +| API key — `VZ-M2M-Token` header | `VzM2MToken` | `string` | +| API key — `SessionToken` header | `SessionToken` | `string` | +| OAuth 2.0 client credentials | `ThingspaceOauth` | `OAuth2ClientCredentials` | +| OAuth 2.0 client credentials | `ThingspaceOauth1` | `OAuth2ClientCredentials` | -```csharp -using {RootNamespace}.Core.Authentication.Basic; +**That table is the entire authentication surface of this SDK.** `Verizon.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` +## API key (header) — `VzM2MToken` -Sends `Authorization: Basic base64(username:password)`. +**API Provider Notes:** -## Bearer token +> M2M Session Token ([How to generate an M2M session token?]($e/Session%20Management/StartConnectivityManagementSession)) -Set the configured token property on the options class to your access-token string: +The key travels in the `VZ-M2M-Token` request header. The header name is fixed by the generated scheme — you supply only the value: ```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; +options.VzM2MToken = "API_KEY"; ``` -Sends `Authorization: Bearer ACCESS_TOKEN`. +## API key (header) — `SessionToken` -## API key (header, query, or cookie) +**API Provider Notes:** -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: +> This is the Session/M2M token needed to authenticate the user. It should be acquired by using the ThingSpace APIs. For detail on how to obtain a Session/M2M token please refer to the +> - [ThingSpace Quick Start Guide - Obtaining a VZ-M2M Session Token Programmatically](https://thingspace.verizon.com/documentation/api-documentation.html#/http/quick-start/credentials-and-tokens/obtaining-a-vz-m2m-sessiontoken-programmatically) +> - or the [ThingSpace API Video Guide 1](https://www.youtube.com/watch?v=QPJQFT3637w) and [ThingSpace API Video Guide 2](https://www.youtube.com/watch?v=hc9udGp4P_s) + +The key travels in the `SessionToken` request header. The header name is fixed by the generated scheme — you supply only the value: ```csharp -options.{ApiKeyProperty} = "API_KEY"; +options.SessionToken = "API_KEY"; ``` -## OAuth 2.0 — client credentials (machine-to-machine) +## OAuth 2.0 — client credentials (machine-to-machine) — `ThingspaceOauth` + +**API Provider Notes:** + +> This is the ThingSpace token, from [Credentials and Tokens](https://thingspace.verizon.com/documentation/api-documentation.html#/http/quick-start/credentials-and-tokens) +> is used ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; +using Verizon.Core.Authentication.OAuth2.ClientCredentials; -options.{OAuthProperty} = new OAuth2ClientCredentials +options.ThingspaceOauth = new OAuth2ClientCredentials { ClientId = "...", ClientSecret = "...", @@ -69,177 +60,104 @@ options.{OAuthProperty} = new OAuth2ClientCredentials }; ``` -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. +The SDK fetches the token from `https://thingspace.verizon.com/api/ts/v1/oauth2/token` and caches it, acquiring a fresh one when it expires; on a `401` it invalidates the cached token and re-acquires. The token request carries the client id and secret as HTTP Basic credentials, so `ClientSecret` is required. + +## OAuth 2.0 — client credentials (machine-to-machine) — `ThingspaceOauth1` -## OAuth 2.0 — authorization code (3-legged, with PKCE) +**API Provider Notes:** + +> This is where the ThingSpace access token, from [Credentials and Tokens](https://thingspace.verizon.com/documentation/api-documentation.html#/http/quick-start/credentials-and-tokens) is used ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; +using Verizon.Core.Authentication.OAuth2.ClientCredentials; -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials +options.ThingspaceOauth1 = new OAuth2ClientCredentials { ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE - RedirectUri = "https://app.example.com/callback", - Scope = "...", // optional - State = "...", // optional CSRF token - Pkce = PkceMethod.S256, // default; RFC 7636 - PromptForAuthorizationCode = async (authorizationUrl, ct) => - { - // Open/redirect the browser to authorizationUrl, then return the - // authorization code your redirect endpoint received. - return await GetCodeFromUserAsync(authorizationUrl, ct); - } + ClientSecret = "...", + Scope = "..." // optional }; ``` -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. - -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: +The SDK fetches the token from `https://thingspace.verizon.com/api/ts/v1/` and caches it, acquiring a fresh one when it expires; on a `401` it invalidates the cached token and re-acquires. The token request carries the client id and secret as HTTP Basic credentials, so `ClientSecret` is required. -| PKCE | token request built | no secret ⇒ | -| --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | - -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. - -## OAuth 2.0 — resource owner password - -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; +## Token caching & refresh -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` +- Tokens are cached in-memory, **per client instance**, and reused until **30s** before expiry. +- **Nothing here refreshes.** The grants above are not wired to `IOAuth2RefreshableTokenStrategy` — when a token expires the whole grant re-runs. A `refresh_token` in the response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do not infer refresh behaviour from what the provider returns. +- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is **not** retried. +- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of a fresh token. -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. +⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** `expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to "not expired" when it is absent — so the first token is cached for the life of the client and the only thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. ### Supplying your own token source -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. +The token strategy is a public extension point: `VerizonClientOptions` exposes a strategy property beside each credentials property, and the generated client falls back to the built-in strategy only when you leave it null. The strategy is used only when the credentials property beside it is also set, and `GetToken` receives that object. + +| Grant | Strategy property on `VerizonClientOptions` | Type | +| --- | --- | --- | +| Client credentials | `ThingspaceOauthTokenStrategy` | `IOAuth2TokenStrategy` | +| Client credentials | `ThingspaceOauth1TokenStrategy` | `IOAuth2TokenStrategy` | ```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) +options.ThingspaceOauth = new OAuth2ClientCredentials { /* its required members */ }; +options.ThingspaceOauthTokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) ``` -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes +Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test double. The per-client in-memory cache above still wraps whatever you return. -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: +## Combining the schemes -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). +This SDK generates more than one credentials property, and its operations compose them. You configure the composition by setting the relevant properties on `VerizonClientOptions` — the generated client wires the rest. -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. +**AND (`AuthSchemeAll`) — every scheme in the group is applied to the request.** Leave one of them unconfigured and the request still goes out, just without that credential; the provider decides what to make of a half-authenticated call. -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +**OR (`AuthSchemeAny`) — alternatives, tried in order.** Schemes with **no credentials configured are skipped, not tried**; the first configured scheme that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails. Configure exactly the one you intend to use — an extra credential set "just in case" changes which scheme authenticates the call. **Which one that is, is not a preference:** each scheme's **API Provider Notes** above are where the provider states when its scheme applies. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `VerizonClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("VerizonClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.VzM2MToken)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "VerizonClient:VzM2MToken is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"VerizonClient:VzM2MToken is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +Check every value **the scheme you chose** needs. The rows below are not one checklist: the schemes above are alternatives, so a row belonging to a scheme you are not using needs no value at all, and demanding one at startup blocks a boot that would have worked. Validate the rows whose property you set, and no others: + +| Value to validate | Feeds `VerizonClientOptions` property | +| --- | --- | +| `VzM2MToken` | `VzM2MToken` | +| `SessionToken` | `SessionToken` | +| `ClientId` | `ThingspaceOauth` | +| `ClientSecret` | `ThingspaceOauth` | +| `ClientId` | `ThingspaceOauth1` | +| `ClientSecret` | `ThingspaceOauth1` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddVerizonClient(options => ...)` callback when registering via DI. `VerizonClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddVerizonClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/verizon/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/verizon/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/verizon/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/verizon/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/verizon/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/verizon/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/verizon/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/verizon/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/verizon/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/verizon/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/verizon/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/verizon/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/verizon/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/verizon/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/verizon/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/verizon/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/verizon/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/verizon/skills/dotnet/dotnet-getting-started/SKILL.md index 0aa1389d4..52c19a05e 100644 --- a/plugins/verizon/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/verizon/skills/dotnet/dotnet-getting-started/SKILL.md @@ -11,7 +11,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity @@ -26,7 +26,7 @@ This is the **SDK-specific** entry point. For general patterns that apply to any | Auth | **OAuth 2.0** client credentials — set `ThingspaceOauth`
**API key** in the `VZ-M2M-Token` header — set `VzM2MToken`
**API key** in the `SessionToken` header — set `SessionToken`
**OAuth 2.0** client credentials — set `ThingspaceOauth1` | | Environments | `ServerEnvironment.Production` *(default)* → `HyperPreciseCredentials` `https://thingspace.verizon.com/api/auth/v1`, `ImpServer` `https://imp.thingspace.verizon.com`, `Thingspace` `https://thingspace.verizon.com/api`, `OAuthServer` `https://thingspace.verizon.com/api/ts/v1`, `M2M` `https://thingspace.verizon.com/api/m2m`, `DeviceLocation` `https://thingspace.verizon.com/api/loc/v1`, `SubscriptionServer` `https://thingspace.verizon.com/api/subsc/v1`, `SoftwareManagementV1` `https://thingspace.verizon.com/api/fota/v1`, `SoftwareManagementV2` `https://thingspace.verizon.com/api/fota/v2`, `SoftwareManagementV3` `https://thingspace.verizon.com/api/fota/v3`, `DeviceDiagnostics` `https://thingspace.verizon.com/api/diagnostics/v1`, `CloudConnector` `https://thingspace.verizon.com/api/cc/v1`, `HyperPreciseLocation` `https://thingspace.verizon.com/api/hyper-precise/v1`, `Services` `https://5gedge.verizon.com/api/mec/services`, `QualityOfService` `https://thingspace.verizon.com/api/m2m/v1/devices`
`ServerEnvironment.Staging` → `HyperPreciseCredentials` `https://staging.thingspace.verizon.com/api/auth/v1`, `ImpServer` `https://imp-staging.thingspace.verizon.com`, `Thingspace` `https://staging.thingspace.verizon.com/api`, `OAuthServer` `https://staging.thingspace.verizon.com/api/ts/v1`, `M2M` `https://staging.thingspace.verizon.com/api/m2m`, `DeviceLocation` `https://staging.thingspace.verizon.com/api/loc/v1`, `SubscriptionServer` `https://staging.thingspace.verizon.com/api/subsc/v1`, `SoftwareManagementV1` `https://staging.thingspace.verizon.com/api/fota/v1`, `SoftwareManagementV2` `https://staging.thingspace.verizon.com/api/fota/v2`, `SoftwareManagementV3` `https://staging.thingspace.verizon.com/api/fota/v3`, `DeviceDiagnostics` `https://staging.thingspace.verizon.com/api/diagnostics/v1`, `CloudConnector` `https://staging.thingspace.verizon.com/api/cc/v1`, `HyperPreciseLocation` `https://staging.thingspace.verizon.com/api/hyper-precise/v1`, `Services` `https://staging.5gedge.verizon.com/api/mec/services`, `QualityOfService` `https://staging.thingspace.verizon.com/api/m2m/v1/devices`
`ServerEnvironment.Dev` → `HyperPreciseCredentials` `https://staging.thingspace.verizon.com/api/auth/v1`, `ImpServer` `https://devmanagement-staging.imp.thingspace.verizon.com`, `Thingspace` `https://devmanagement-staging.thingspace.verizon.com/api`, `OAuthServer` `https://devmanagement-staging.thingspace.verizon.com:80/ts/v1`, `M2M` `https://devmanagement-staging.thingspace.verizon.com:80/m2m`, `DeviceLocation` `https://devmanagement-staging.thingspace.verizon.com:80/loc/v1`, `SubscriptionServer` `https://devmanagement-staging.thingspace.verizon.com:80/subsc/v1`, `SoftwareManagementV1` `https://devmanagement-staging.thingspace.verizon.com:80/fota/v1`, `SoftwareManagementV2` `https://devmanagement-staging.thingspace.verizon.com:80/fota/v2`, `SoftwareManagementV3` `https://devmanagement-staging.thingspace.verizon.com:80/fota/v3`, `DeviceDiagnostics` `https://devmanagement-staging.thingspace.verizon.com:80/diagnostics/v1`, `CloudConnector` `https://devmanagement-staging.thingspace.verizon.com:80/cc/v1`, `HyperPreciseLocation` `https://devmanagement-staging.thingspace.verizon.com:80/hyper-precise/v1`, `Services` `https://devmanagement-staging.5gedge.verizon.com:80/mec/services`, `QualityOfService` `https://devmanagement-staging.thingspace.verizon.com/api/m2m/v1/devices`
`ServerEnvironment.Qa` → `HyperPreciseCredentials` `https://thingspace.verizon.com/api/auth/v1`, `ImpServer` `https://tsd-nginx-qa-us-east-1.imp.thingspace.verizon.com`, `Thingspace` `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api`, `OAuthServer` `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/ts/v1`, `M2M` `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/m2m`, `DeviceLocation` `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/loc/v1`, `SubscriptionServer` `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/subsc/v1`, `SoftwareManagementV1` `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/fota/v1`, `SoftwareManagementV2` `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/fota/v2`, `SoftwareManagementV3` `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/fota/v3`, `DeviceDiagnostics` `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/diagnostics/v1`, `CloudConnector` `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/cc/v1`, `HyperPreciseLocation` `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/hyper-precise/v1`, `Services` `https://tsd-nginx-qa-us-east-1.5gedge.verizon.com/api/mec/services`, `QualityOfService` `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/m2m/v1/devices`
`ServerEnvironment.MockServerForLimitedAvailabilitySeeQuickStart` → `HyperPreciseCredentials` `https://staging.thingspace.verizon.com/api/auth/v1`, `ImpServer` `https://mock-staging.thingspace.verizon.com`, `Thingspace` `https://mock-staging.thingspace.verizon.com/api`, `OAuthServer` `https://mock-staging.thingspace.verizon.com/api/ts/v1`, `M2M` `https://mock-staging.thingspace.verizon.com/api/m2m`, `DeviceLocation` `https://mock-staging.thingspace.verizon.com/api/loc/v1`, `SubscriptionServer` `https://mock-staging.thingspace.verizon.com/api/subsc/v1`, `SoftwareManagementV1` `https://mock-staging.thingspace.verizon.com/api/fota/v1`, `SoftwareManagementV2` `https://mock-staging.thingspace.verizon.com/api/fota/v2`, `SoftwareManagementV3` `https://mock-staging.thingspace.verizon.com/api/fota/v3`, `DeviceDiagnostics` `https://mock-staging.thingspace.verizon.com/api/diagnostics/v1`, `CloudConnector` `https://mock-staging.thingspace.verizon.com/api/cc/v1`, `HyperPreciseLocation` `https://mock-staging.thingspace.verizon.com/api/hyper-precise/v1`, `Services` `https://mock-staging.thingspace.verizon.com/api/mec/services`, `QualityOfService` `https://mock-staging.thingspace.verizon.com/api/m2m/v1/devices` | | Log environment variable | `VERIZONCLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-verizon` makes that binding); the **Integration workflow** below says which governs which step. @@ -44,22 +44,24 @@ Models, enums, union types and the generated error types each sit in their own c ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Verizon, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/Verizon/Models/`, `src/Verizon/Errors/` and `src/Verizon/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/Verizon/Models/`, enums `src/Verizon/Models/Enums/`, typed errors `src/Verizon/Errors/`, request records `src/Verizon/Requests/`, or `src/Verizon/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: @@ -69,7 +71,7 @@ Keep lookups cheap — the rules that keep a session's context small: ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/verizon-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/verizon-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. **Clone once, up front**, into a fresh timestamped folder under `/verizon-sdk-src/`, and reuse that folder for the rest of your session: @@ -87,30 +89,31 @@ git clone --filter=blob:none --branch main https://github.com/context-plugins/ve # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/verizon-sdk-src/`, **never** in the project repo, and its path never goes into `verizon-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `verizon-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `VerizonClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/Verizon/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/Verizon/Models/` (+ `src/Verizon/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/Verizon/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/Verizon/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/Verizon/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/Verizon/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/Verizon/Servers/`, `src/Verizon/VerizonClient.cs`, `src/Verizon/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Verizon may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything @@ -126,7 +129,7 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/Verizon/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step @@ -134,9 +137,9 @@ You loaded every companion the sheet named before starting (per `dotnet-integrat 1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new VerizonClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) 2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `VerizonClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/verizon/skills/dotnet/dotnet-integrate-verizon/SKILL.md b/plugins/verizon/skills/dotnet/dotnet-integrate-verizon/SKILL.md index 815966a1a..bec2372c5 100644 --- a/plugins/verizon/skills/dotnet/dotnet-integrate-verizon/SKILL.md +++ b/plugins/verizon/skills/dotnet/dotnet-integrate-verizon/SKILL.md @@ -11,7 +11,7 @@ You are the Verizon .NET SDK specialist for this task — one agent for every SD ## Grounding rules -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `verizon-plan.md` — the plan must stay portable. +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `verizon-plan.md` never carries its path — the plan must stay portable. - **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. - **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. - **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. @@ -29,7 +29,7 @@ Your FIRST deliverable is `verizon-plan.md` at `/verizon-plan #### How to ground (map-first, one pass) 1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. 3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. 4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. 5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. @@ -38,7 +38,7 @@ Your FIRST deliverable is `verizon-plan.md` at `/verizon-plan These need no SDK knowledge and touch no project file, so do them before or alongside the map work: -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); - the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; - locating the SDK reference and its version in the project; @@ -54,10 +54,23 @@ Never use this prerequisite work as a "head start" on implementation: **creating #### The `verizon-plan.md` format (keep it tight — tables, not prose) 1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + 3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. 6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. 7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. @@ -67,12 +80,46 @@ Never use this prerequisite work as a "head start" on implementation: **creating | 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | | 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | | 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | | 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | | 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | | 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. @@ -95,14 +142,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. 3. After every change: `dotnet build`; fix non-SDK errors yourself. 4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `Verizon.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. ### Step 4 — Fixing SDK errors (map-first, in place) A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `Verizon.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/Verizon/Models/{Type}.cs`, `src/Verizon/Models/Enums/{Type}.cs`, `src/Verizon/Errors/{Type}.cs`, `src/Verizon/Requests/{Type}.cs`, or `src/Verizon/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. 3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". 4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). 5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. @@ -120,6 +168,6 @@ A standalone Verizon question with no code change: load `dotnet-getting-started` - **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. - **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. - **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search Verizon for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `Verizon` is what the generated source declares, not what public docs describe. +- **Do not web-search Verizon for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `Verizon` is what the generated source declares, not what public docs describe. - **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. diff --git a/plugins/verizon/skills/dotnet/dotnet-models/SKILL.md b/plugins/verizon/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/verizon/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/verizon/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/verizon/skills/dotnet/dotnet-models/reference.md b/plugins/verizon/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/verizon/skills/dotnet/dotnet-models/reference.md +++ b/plugins/verizon/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/verizon/skills/dotnet/dotnet-testing/SKILL.md b/plugins/verizon/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/verizon/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/verizon/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/verizon/skills/python/python-authentication/SKILL.md b/plugins/verizon/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/verizon/skills/python/python-authentication/SKILL.md +++ b/plugins/verizon/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/verizon/skills/python/python-calling-endpoints/SKILL.md b/plugins/verizon/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/verizon/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/verizon/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/verizon/skills/python/python-client-initialization/SKILL.md b/plugins/verizon/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/verizon/skills/python/python-client-initialization/SKILL.md +++ b/plugins/verizon/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/verizon/skills/python/python-configuration-resilience/SKILL.md b/plugins/verizon/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/verizon/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/verizon/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/verizon/skills/python/python-error-handling/SKILL.md b/plugins/verizon/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/verizon/skills/python/python-error-handling/SKILL.md +++ b/plugins/verizon/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/verizon/skills/python/python-event-streams/SKILL.md b/plugins/verizon/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/verizon/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/verizon/skills/python/python-file-handling/SKILL.md b/plugins/verizon/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/verizon/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/verizon/skills/python/python-getting-started/SKILL.md b/plugins/verizon/skills/python/python-getting-started/SKILL.md index cd7ecff46..12c62a25e 100644 --- a/plugins/verizon/skills/python/python-getting-started/SKILL.md +++ b/plugins/verizon/skills/python/python-getting-started/SKILL.md @@ -25,12 +25,12 @@ Verified against `verizon/` and `pyproject.toml` of the generated package at ver | Version | `v1.0` | | Sync client class | `VerizonClient` (alias `Client`) | | Async client class | `AsyncVerizonClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `environment` · `timeout` (default `30.0`) · `server_config` · `thingspace_oauth` · `thingspace_oauth_token_source` · `vz_m2_m_token` · `session_token` · `thingspace_oauth1` · `thingspace_oauth1_token_source`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Client construction | **keyword-only**: `environment` · `timeout` (default `30.0`) · `server_config` · `retry_options` · `thingspace_oauth` · `thingspace_oauth_token_source` · `vz_m2_m_token` · `session_token` · `thingspace_oauth1` · `thingspace_oauth1_token_source`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | **OAuth 2.0** client credentials — set `thingspace_oauth` · **API key** in the `VZ-M2M-Token` header — set `vz_m2_m_token` · **API key** in the `SessionToken` header — set `session_token` · **OAuth 2.0** client credentials — set `thingspace_oauth1` | | Environments | 5 environments (default `"production"`) × 15 named servers, through `server_config` | | Base-URL config | `ServerConfig` (`verizon/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | | Surface | 314 operations across 88 controllers · 781 models · 50 unions · 61 enums · 229 per-operation error unions | @@ -39,13 +39,18 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash pip install "verizon @ git+https://github.com/context-plugins/verizon-python-sdk.git@main" +python -c "import verizon, pathlib; print(pathlib.Path(verizon.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules @@ -72,7 +77,7 @@ Everything else comes from its own subpackage, and the split matters because eac | --- | --- | | Domain models, their `…Dict` companions | `verizon.models` | | Enums (and their open `…OrStr`/`…OrInt` aliases) | `verizon.models.enums` | -| `ApiError` · `RawError` · `ApiResult` · `RequestOptions` · `ClientCredentials` · `HttpClient` · `SdkBaseModel` · `UNSET` · `Optional` | `verizon.core` | +| `ApiError` · `RawError` · `ApiResult` · `RequestOptions` · `ClientCredentials` · `HttpClient` · `SdkBaseModel` · `UNSET` · `Optional` · `FileInput` · `AsyncFileInput` · `NamedFile` · `BinaryContent` · `AsyncBinaryContent` · `BinaryReader` · `AsyncBinaryReader` | `verizon.core` | | Per-operation error *unions* | `verizon.errors` (`ActivateDeviceThroughProfileErrorBody`, …) | `verizon.core` re-exports its whole public surface (a curated `__all__`), so import from `…core` rather than from the private modules beneath it (`…core.results`, `…core.exceptions`, `…core.auth.schemes`). @@ -264,15 +269,15 @@ The SDK map's controller table carries the same counts and links to a page per c The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `verizon/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `verizon/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `verizon/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/verizon-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. Every `Source` path on the map is relative to that SDK root, so `verizon/models/success201.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. @@ -292,7 +297,7 @@ Read the one module that owns the fact **inside the installed package**. Locate python -c "import verizon, pathlib; print(pathlib.Path(verizon.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/verizon` on Windows, `.venv/lib/python3.*/site-packages/verizon` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/verizon` on Windows, `.venv/lib/python3.*/site-packages/verizon` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -300,7 +305,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/ve | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -309,8 +314,9 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/ve | An enum's members and wire values | `models/enums/` | | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | +| What a file or binary parameter accepts, and the reader protocols behind it (the aliases are in the import table above; not in the map's Type sources) | `core/files.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | | An operation's error mapper (status → schema) | `errors/_error.py` | @@ -327,13 +333,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The four schemes are `thingspace_oauth=`, `vz_m2_m_token=`, `session_token=` and `thingspace_oauth1=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; the token is fetched lazily and cached by the client; and a **failed token fetch** raises something other than what a caller expects and **bypasses the non-raising response mode** entirely. Load secrets from the environment or a secret store, never hardcode.) -3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; **18 operations return `None`**, so `with_raw_response` is the only way to observe their status code; and the two response modes — raising vs `ApiResult` — behave differently on failure.) +3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; **18 operations return `None`**, so `with_raw_response` is the only way to observe their status code; a multipart body is **one flat parameter list** — every field, JSON part and file is its own parameter, and an optional part is omitted with `None` rather than by a second call shape; and the two response modes — raising vs `ApiResult` — behave differently on failure.) (*For the file positions:* load **python-file-handling** before you pass anything but a `Path` — a file position accepts far more than `bytes`, **never a `str`**, and nothing validates what you hand it.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`/`…OrInt`), so an unknown wire value passes through as a plain `str`/`int` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -341,9 +347,10 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 1. **Sync or async** — which client class, and the reminder that the two do not mix. Plus the `close()`/`aclose()` obligation and where the client is held. 2. **The keyword-only boundary** for each operation: what is positional (path params, sometimes the body) and what sits after `*`. Every keyword-only parameter has a real default, so there is no "must pass `None` explicitly" hazard — say so, so nobody writes defensive `None`s. -3. **The 18 operations that return `None`** — `client_logging.disable_device_logging` · `client_logging.disable_logging_for_devices` · `cloud_connector_devices.delete_device_from_account` · `cloud_connector_subscriptions.delete_subscription` · `etxapp_configuration.delete_configuration` · `etxapp_configuration.update_configuration` · `etxregistration.unregister_etx_clients` · `hyper_precise_location_callbacks.deregister_callback6` · `sensor_insights_notification_groups.sensor_insights_add_users_to_notification_group_request` · `sensor_insights_notification_groups.sensor_insights_delete_notification_group` · `sensor_insights_notification_groups.sensor_insights_remove_users_from_notification_group_request` · `sensor_insights_sensors.sensor_insights_off_board_sensor_request` · `sensor_insights_sensors.sensor_insights_on_board_sensor_request` · `sensor_insights_users.sensor_insights_delete_user` · `software_management_callbacks_v1.deregister_callback3` · `software_management_licenses_v1.delete_list_of_licenses_to_remove` · `targets.delete_target` · `map_message_controller.delete_map_message`. Their raw peers are `ApiResult[None, …]`, so `with_raw_response` is the only way to observe the status code. -4. **Required vs `UNSET`** for every model member the task sets, and the fact that `Optional[T]` here is `T | UnsetType` — **not** `typing.Optional`, so `None` is not a legal value for it. -5. **The `ApiError.error` union** for each operation in scope — there are **23** typed error bodies in this SDK, so the union is never uniform. Every union is ` | RawError`: +3. **The file positions** — `configuration_files.upload_config_file`, each annotated `FileInput` on the sync client and `AsyncFileInput` on the async one, with `list[…]` for an array part. What those admit is wider than the annotation shows and **never a `str`**, and the arm you pick decides what stays resident, who closes your handle and what the file is called on the wire — so the sheet cites **python-file-handling** for the row rather than restating it. +4. **The 18 operations that return `None`** — `client_logging.disable_device_logging` · `client_logging.disable_logging_for_devices` · `cloud_connector_devices.delete_device_from_account` · `cloud_connector_subscriptions.delete_subscription` · `etxapp_configuration.delete_configuration` · `etxapp_configuration.update_configuration` · `etxregistration.unregister_etx_clients` · `hyper_precise_location_callbacks.deregister_callback6` · `sensor_insights_notification_groups.sensor_insights_add_users_to_notification_group_request` · `sensor_insights_notification_groups.sensor_insights_delete_notification_group` · `sensor_insights_notification_groups.sensor_insights_remove_users_from_notification_group_request` · `sensor_insights_sensors.sensor_insights_off_board_sensor_request` · `sensor_insights_sensors.sensor_insights_on_board_sensor_request` · `sensor_insights_users.sensor_insights_delete_user` · `software_management_callbacks_v1.deregister_callback3` · `software_management_licenses_v1.delete_list_of_licenses_to_remove` · `targets.delete_target` · `map_message_controller.delete_map_message`. Their raw peers are `ApiResult[None, …]`, so `with_raw_response` is the only way to observe the status code. +5. **Required vs `UNSET`** for every model member the task sets, and the fact that `Optional[T]` here is `T | UnsetType` — **not** `typing.Optional`, so `None` is not a legal value for it. +6. **The `ApiError.error` union** for each operation in scope — there are **23** typed error bodies in this SDK, so the union is never uniform. Every union is ` | RawError`: 1. `ConnectivityManagementResult` — 51 operations (`account_requests.get_current_asynchronous_request_status` · `accounts.get_account_information` · `accounts.list_account_leads` · `accounts.list_account_states_and_services` · `connectivity_callbacks.deregister_callback` · `connectivity_callbacks.list_registered_callbacks` · `connectivity_callbacks.register_callback` · `device_diagnostics.device_reachability_status_using_post` · `device_diagnostics.retrieve_active_monitors_using_post` · `device_groups.create_device_group` · `device_groups.delete_device_group` · `device_groups.get_device_group_information` · `device_groups.list_device_groups` · `device_groups.update_device_group` · `device_management.activate_service_for_devices` · `device_management.add_devices` · `device_management.billed_usage_info` · `device_management.change_devices_service_plan` · `device_management.check_devices_availability_for_activation` · `device_management.deactivate_service_for_devices` · `device_management.delete_deactivated_devices` · `device_management.device_upload_status` · `device_management.get_device_extended_diagnostic_information` · `device_management.get_device_service_suspension_status` · `device_management.list_current_devices_prl_version` · `device_management.list_devices_information` · `device_management.list_devices_provisioning_history` · `device_management.list_devices_usage_history` · `device_management.list_devices_with_imei_iccid_mismatch` · `device_management.move_devices_within_accounts_of_profile` · `device_management.restore_service_for_suspended_devices` · `device_management.retrieve_aggregate_device_usage_history` · `device_management.retrieve_device_connection_history` · `device_management.suspend_service_for_devices` · `device_management.update_device_id` · `device_management.update_devices_contact_information` · `device_management.update_devices_cost_center_code` · `device_management.update_devices_custom_fields` · `device_management.update_devices_state` · `device_management.upload_activate_device` · `device_management.usage_segmentation_label_association` · `device_management.usage_segmentation_label_deletion` · `sms.list_devices_sms_messages` · `sms.send_sms_to_device` · `sms.start_queued_sms_delivery` · `service_plans.list_account_service_plans` · `session_management.end_connectivity_management_session` · `session_management.reset_connectivity_management_password` · `session_management.start_connectivity_management_session` · `e_uicc_device_profile_management.download_local_profile_to_disable` · `e_uicc_device_profile_management.download_local_profile_to_enable`); distinguishing members *none required* 2. `ManagementError400` — 33 operations (`sensor_insights_device_profile.create_a_profile` · `sensor_insights_device_profile.delete_a_profile` · `sensor_insights_device_profile.query_a_profile` · `sensor_insights_device_profile.update_a_profile` · `sensor_insights_devices.sensor_insights_device_action_set_request` · `sensor_insights_devices.sensor_insights_last_reported_time_request` · `sensor_insights_devices.sensor_insights_list_device_experience_history_request` · `sensor_insights_devices.sensor_insights_list_network_experience_history_request` · `sensor_insights_devices.sensor_insights_patch_device_request` · `sensor_insights_gateways.sensor_insights_list_gateway_devices_request` · `sensor_insights_health_score.sensor_insights_get_network_health_score_response` · `sensor_insights_health_score.sensor_insights_health_score_summary` · `sensor_insights_notification_groups.sensor_insights_add_users_to_notification_group_request` · `sensor_insights_notification_groups.sensor_insights_create_notification_group_request` · `sensor_insights_notification_groups.sensor_insights_delete_notification_group` · `sensor_insights_notification_groups.sensor_insights_list_notification_group_request` · `sensor_insights_notification_groups.sensor_insights_remove_users_from_notification_group_request` · `sensor_insights_notification_groups.sensor_insights_update_notification_group_request` · `sensor_insights_rules.sensor_insights_list_rules_request` · `sensor_insights_rules.sensor_insights_overwrite_rule_request` · `sensor_insights_sensors.sensor_insights_list_sensor_devices_request` · `sensor_insights_sensors.sensor_insights_off_board_sensor_request` · `sensor_insights_sensors.sensor_insights_on_board_sensor_request` · `sensor_insights_sensors.sensor_insights_sensor_off_boarding_status_request` · `sensor_insights_sensors.sensor_insights_sensor_on_board_status_request` · `sensor_insights_smart_alert_metrics.sensorinsightsmetricsquery` · `sensor_insights_smart_alerts.sensor_insights_bulk_update` · `sensor_insights_smart_alerts.sensor_insights_list_smart_alerts_request` · `sensor_insights_smart_alerts.sensor_insights_patch_smart_alert_request` · `sensor_insights_users.sensor_insights_create_user_request` · `sensor_insights_users.sensor_insights_delete_user` · `sensor_insights_users.sensor_insights_list_user_request` · `sensor_insights_users.sensor_insights_update_user_request`); distinguishing members *none required* 3. `ManagementError403` — 33 operations (`sensor_insights_device_profile.create_a_profile` · `sensor_insights_device_profile.delete_a_profile` · `sensor_insights_device_profile.query_a_profile` · `sensor_insights_device_profile.update_a_profile` · `sensor_insights_devices.sensor_insights_device_action_set_request` · `sensor_insights_devices.sensor_insights_last_reported_time_request` · `sensor_insights_devices.sensor_insights_list_device_experience_history_request` · `sensor_insights_devices.sensor_insights_list_network_experience_history_request` · `sensor_insights_devices.sensor_insights_patch_device_request` · `sensor_insights_gateways.sensor_insights_list_gateway_devices_request` · `sensor_insights_health_score.sensor_insights_get_network_health_score_response` · `sensor_insights_health_score.sensor_insights_health_score_summary` · `sensor_insights_notification_groups.sensor_insights_add_users_to_notification_group_request` · `sensor_insights_notification_groups.sensor_insights_create_notification_group_request` · `sensor_insights_notification_groups.sensor_insights_delete_notification_group` · `sensor_insights_notification_groups.sensor_insights_list_notification_group_request` · `sensor_insights_notification_groups.sensor_insights_remove_users_from_notification_group_request` · `sensor_insights_notification_groups.sensor_insights_update_notification_group_request` · `sensor_insights_rules.sensor_insights_list_rules_request` · `sensor_insights_rules.sensor_insights_overwrite_rule_request` · `sensor_insights_sensors.sensor_insights_list_sensor_devices_request` · `sensor_insights_sensors.sensor_insights_off_board_sensor_request` · `sensor_insights_sensors.sensor_insights_on_board_sensor_request` · `sensor_insights_sensors.sensor_insights_sensor_off_boarding_status_request` · `sensor_insights_sensors.sensor_insights_sensor_on_board_status_request` · `sensor_insights_smart_alert_metrics.sensorinsightsmetricsquery` · `sensor_insights_smart_alerts.sensor_insights_bulk_update` · `sensor_insights_smart_alerts.sensor_insights_list_smart_alerts_request` · `sensor_insights_smart_alerts.sensor_insights_patch_smart_alert_request` · `sensor_insights_users.sensor_insights_create_user_request` · `sensor_insights_users.sensor_insights_delete_user` · `sensor_insights_users.sensor_insights_list_user_request` · `sensor_insights_users.sensor_insights_update_user_request`); distinguishing members *none required* @@ -369,8 +376,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 23. `str` — 1 operation (`device_role_controller.get_acl_rules_by_vendor_id`); distinguishing members *none required* 24. *(none)* — `gbi_device_actions5.business_internet_serviceplanchange` · `gbi_device_actions5.business_internetactivate_using_post` · `gbi_device_actions5.business_internetlist_device_information` · `anomaly_settings.activate_anomaly_detection` · `anomaly_settings.list_anomaly_detection_settings` · `anomaly_settings.reset_anomaly_detection_parameters` · `anomaly_triggers.delete_anomaly_detection_trigger` · `anomaly_triggers_v2.create_anomaly_detection_trigger_v2` · `anomaly_triggers_v2.list_anomaly_detection_trigger_settings_v2` · `anomaly_triggers_v2.update_anomaly_detection_trigger_v2` · `cloud_connector_devices.delete_device_from_account` · `cloud_connector_devices.find_device_by_property_values` · `cloud_connector_devices.search_device_event_history` · `cloud_connector_devices.search_devices_resources_by_property_values` · `cloud_connector_devices.search_sensor_readings` · `cloud_connector_devices.update_devices_configuration_value` · `cloud_connector_subscriptions.create_subscription` · `cloud_connector_subscriptions.delete_subscription` · `cloud_connector_subscriptions.query_subscription` · `create_price_plan_triggers.create_trigger_rules` · `device_actions.account_information` · `device_actions.aggregate_usage` · `device_actions.daily_usage` · `device_actions.get_asynchronous_request_status` · `device_actions.retrieve_device_provisioning_history` · `device_actions.retrieve_the_global_device_list` · `device_actions.service_plan_list` · `device_location_callbacks.cancel_async_report` · `device_sms_messaging.get_sms_messages` · `device_sms_messaging.list_sms_message_history` · `device_sms_messaging.send_an_sms_message` · `device_sms_messaging.start_sms_message_delivery` · `devices_locations.cancel_queued_location_report_generation` · `devices_locations.create_location_report` · `devices_locations.get_location_report_status` · `devices_locations.list_devices_locations_asynchronous` · `devices_locations.list_devices_locations_synchronous` · `devices_locations.retrieve_location_report` · `diagnostics_factory_reset.decives_restart` · `diagnostics_history.get_diagnostics_history` · `diagnostics_observations.start_diagnostics_observation` · `diagnostics_observations.stop_diagnostics_observation` · `diagnostics_settings.list_diagnostics_settings` · `diagnostics_subscriptions.get_diagnostics_subscription` · `exclusions.devices_location_get_consent_async` · `exclusions.devices_location_give_consent_async` · `exclusions.devices_location_update_consent` · `managing_e_sim_profiles.activate_a_device_profile` · `managing_e_sim_profiles.deactivate_a_device_profile` · `managing_e_sim_profiles.delete_a_device_profile` · `managing_e_sim_profiles.device_suspend` · `managing_e_sim_profiles.download_a_device_profile` · `managing_e_sim_profiles.enable_a_device_profile` · `managing_e_sim_profiles.enable_a_device_profile_for_download` · `managing_e_sim_profiles.profile_suspend` · `managing_e_sim_profiles.resume_profile` · `managing_e_sim_profiles.set_fallback` · `pwn.change_pwn_device_i_paddress` · `pwn.change_pwn_device_profile` · `pwn.change_pwn_device_state_activate` · `pwn.change_pwn_device_state_deactivate` · `pwn.get_pwn_performance_consent` · `pwn.get_profile_list` · `pwn.kpi_list` · `promotion_period_information.get_promo_device_aggregate_usage_history` · `promotion_period_information.get_promo_device_usage_history` · `retrieve_rate_plan_list.get_rate_plan_list` · `retrieve_the_triggers.get_all_available_triggers` · `retrieve_the_triggers.get_all_triggers_by_account_name` · `retrieve_the_triggers.get_all_triggers_by_trigger_category` · `retrieve_the_triggers.get_triggers_by_id` · `software_management_callbacks_v1.deregister_callback3` · `software_management_licenses_v1.delete_list_of_licenses_to_remove` · `targets.create_azure_central_io_t_application` · `targets.create_target` · `targets.delete_target` · `targets.generate_target_external_id` · `targets.query_target` · `thing_space_quality_of_service_api_actions.create_a_thing_space_quality_of_service_api_subscription` · `thing_space_quality_of_service_api_actions.stop_a_thing_space_quality_of_service_api_subscription` · `update_price_plan_triggers.update_trigger_rules` · `update_triggers.update_all_available_triggers` · `wireless_network_performance.device_experience30days_history` · `wireless_network_performance.device_experience_bulk_latest` · `wireless_network_performance.domestic4_g_and5_g_nationwide_network_coverage` · `wireless_network_performance.near_real_time_network_conditions` · `wireless_network_performance.site_proximity`: no typed arm, so `.error` is always `RawError` 25. So `isinstance(e.error, ConnectivityManagementResult)` matches only 51 of 314 operations. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 77 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"`. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +7. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 77 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +8. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +9. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +10. **Which environment the `environment` keyword selects**, because omitting it is silently `"production"`. +11. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/verizon/skills/python/python-integrate-verizon/SKILL.md b/plugins/verizon/skills/python/python-integrate-verizon/SKILL.md index 39b286ae3..f127799d9 100644 --- a/plugins/verizon/skills/python/python-integrate-verizon/SKILL.md +++ b/plugins/verizon/skills/python/python-integrate-verizon/SKILL.md @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `python-getting-started` is your **lookup layer - **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import verizon, pathlib; print(pathlib.Path(verizon.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import verizon, pathlib; print(pathlib.Path(verizon.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) - **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. @@ -40,6 +40,21 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** creating or activating a virtual environment, installing `verizon` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. @@ -62,8 +77,9 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. 4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `verizon.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `verizon.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. ### Step 3 — Answering pure questions diff --git a/plugins/verizon/skills/python/python-models/SKILL.md b/plugins/verizon/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/verizon/skills/python/python-models/SKILL.md +++ b/plugins/verizon/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/verizon/skills/python/python-models/reference.md b/plugins/verizon/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/verizon/skills/python/python-models/reference.md +++ b/plugins/verizon/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/verizon/skills/python/python-testing/SKILL.md b/plugins/verizon/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/verizon/skills/python/python-testing/SKILL.md +++ b/plugins/verizon/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/verizon/skills/typescript/typescript-authentication/SKILL.md b/plugins/verizon/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/verizon/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/verizon/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/verizon/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/verizon/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/verizon/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/verizon/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/verizon/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/verizon/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/verizon/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/verizon/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/verizon/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/verizon/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/verizon/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/verizon/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/verizon/skills/typescript/typescript-error-handling/SKILL.md b/plugins/verizon/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/verizon/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/verizon/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/verizon/skills/typescript/typescript-getting-started/SKILL.md b/plugins/verizon/skills/typescript/typescript-getting-started/SKILL.md index 7563fb1d7..eabb900ed 100644 --- a/plugins/verizon/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/verizon/skills/typescript/typescript-getting-started/SKILL.md @@ -13,22 +13,24 @@ This is the **SDK-specific** entry point. For general patterns that apply to any ## SDK identity -Verified against `package.json` and `sdk-map.md` of the generated package at version `v1.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. +Verified against `package.json` and `sdk-map.md` of the generated package at version `1.0.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. | Fact | Value | | --- | --- | | API | Verizon | | Package name (what you install, and what you import) | `verizon` — **not on npm**; built from source (see *Install*) | | Import specifier | `verizon` — the package root is the **only** entry; deep imports do not resolve | -| Version | `v1.0` (API spec version `v1.0`) | +| Source repository | https://github.com/context-plugins/verizon-typescript-sdk (branch `main` — the ref this map documents) | +| Version | `1.0.0` (API spec version `v1.0`) | | Client class | `VerizonClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new VerizonClient(clientOptions: Partial = {})` — **every** field is optional, so `new VerizonClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `thingspaceOauth` · `thingspaceOauthStrategy` · `vzM2MToken` · `sessionToken` · `thingspaceOauth1` · `thingspaceOauth1Strategy`. `timeout` defaults to `60_000` ms | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new VerizonClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new VerizonClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `retry` · `fetch` · `thingspaceOauth` · `thingspaceOauthStrategy` · `vzM2MToken` · `sessionToken` · `thingspaceOauth1` · `thingspaceOauth1Strategy`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **OAuth 2 client credentials** — set `ClientOptions.thingspaceOauth`
**API key (header)** — set `ClientOptions.vzM2MToken`
**API key (header)** — set `ClientOptions.sessionToken`
**OAuth 2 client credentials** — set `ClientOptions.thingspaceOauth1` | | Environments | 5 environments (`ServerEnvironment.Production` *(default)*, `ServerEnvironment.Staging`, `ServerEnvironment.Dev`, `ServerEnvironment.Qa`, `ServerEnvironment.MockServerForLimitedAvailabilitySeeQuickStart`) × 15 server groups | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`) | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Base-URL config | `serverOptions..baseUrl` (`src/servers.ts`) | +| Log environment variable | `VERIZON_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Surface | 314 operations · 88 resources · 781 models · 61 open enums · 50 unions · 293 per-operation error subclasses | @@ -37,22 +39,22 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/verizon-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 892 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 892 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { VerizonClient, ServerEnvironment, ResponseError, VerizonError } from "verizon"; +import { VerizonClient, ServerEnvironment, ApiError, VerizonError } from "verizon"; import type { ClientOptions, Success201 } from "verizon"; ``` @@ -70,88 +72,88 @@ Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, | Group | Environment | Base URL | Override at | | --- | --- | --- | --- | -| `hyperPreciseCredentials` | `production` *(default)* | `https://thingspace.verizon.com/api/auth/v1` | `serverOptions.hyperPreciseCredentials.production.baseUrl` | -| `hyperPreciseCredentials` | `staging` | `https://staging.thingspace.verizon.com/api/auth/v1` | `serverOptions.hyperPreciseCredentials.staging.baseUrl` | -| `hyperPreciseCredentials` | `dev` | `https://staging.thingspace.verizon.com/api/auth/v1` | `serverOptions.hyperPreciseCredentials.dev.baseUrl` | -| `hyperPreciseCredentials` | `qa` | `https://thingspace.verizon.com/api/auth/v1` | `serverOptions.hyperPreciseCredentials.qa.baseUrl` | -| `hyperPreciseCredentials` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://staging.thingspace.verizon.com/api/auth/v1` | `serverOptions.hyperPreciseCredentials.mockServerForLimitedAvailabilitySeeQuickStart.baseUrl` | -| `impServer` | `production` *(default)* | `https://imp.thingspace.verizon.com` | `serverOptions.impServer.production.baseUrl` | -| `impServer` | `staging` | `https://imp-staging.thingspace.verizon.com` | `serverOptions.impServer.staging.baseUrl` | -| `impServer` | `dev` | `https://devmanagement-staging.imp.thingspace.verizon.com` | `serverOptions.impServer.dev.baseUrl` | -| `impServer` | `qa` | `https://tsd-nginx-qa-us-east-1.imp.thingspace.verizon.com` | `serverOptions.impServer.qa.baseUrl` | -| `impServer` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com` | `serverOptions.impServer.mockServerForLimitedAvailabilitySeeQuickStart.baseUrl` | -| `thingspace` | `production` *(default)* | `https://thingspace.verizon.com/api` | `serverOptions.thingspace.production.baseUrl` | -| `thingspace` | `staging` | `https://staging.thingspace.verizon.com/api` | `serverOptions.thingspace.staging.baseUrl` | -| `thingspace` | `dev` | `https://devmanagement-staging.thingspace.verizon.com/api` | `serverOptions.thingspace.dev.baseUrl` | -| `thingspace` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api` | `serverOptions.thingspace.qa.baseUrl` | -| `thingspace` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api` | `serverOptions.thingspace.mockServerForLimitedAvailabilitySeeQuickStart.baseUrl` | -| `oAuthServer` | `production` *(default)* | `https://thingspace.verizon.com/api/ts/v1` | `serverOptions.oAuthServer.production.baseUrl` | -| `oAuthServer` | `staging` | `https://staging.thingspace.verizon.com/api/ts/v1` | `serverOptions.oAuthServer.staging.baseUrl` | -| `oAuthServer` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/ts/v1` | `serverOptions.oAuthServer.dev.baseUrl` | -| `oAuthServer` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/ts/v1` | `serverOptions.oAuthServer.qa.baseUrl` | -| `oAuthServer` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/ts/v1` | `serverOptions.oAuthServer.mockServerForLimitedAvailabilitySeeQuickStart.baseUrl` | -| `m2M` | `production` *(default)* | `https://thingspace.verizon.com/api/m2m` | `serverOptions.m2M.production.baseUrl` | -| `m2M` | `staging` | `https://staging.thingspace.verizon.com/api/m2m` | `serverOptions.m2M.staging.baseUrl` | -| `m2M` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/m2m` | `serverOptions.m2M.dev.baseUrl` | -| `m2M` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/m2m` | `serverOptions.m2M.qa.baseUrl` | -| `m2M` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/m2m` | `serverOptions.m2M.mockServerForLimitedAvailabilitySeeQuickStart.baseUrl` | -| `deviceLocation` | `production` *(default)* | `https://thingspace.verizon.com/api/loc/v1` | `serverOptions.deviceLocation.production.baseUrl` | -| `deviceLocation` | `staging` | `https://staging.thingspace.verizon.com/api/loc/v1` | `serverOptions.deviceLocation.staging.baseUrl` | -| `deviceLocation` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/loc/v1` | `serverOptions.deviceLocation.dev.baseUrl` | -| `deviceLocation` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/loc/v1` | `serverOptions.deviceLocation.qa.baseUrl` | -| `deviceLocation` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/loc/v1` | `serverOptions.deviceLocation.mockServerForLimitedAvailabilitySeeQuickStart.baseUrl` | -| `subscriptionServer` | `production` *(default)* | `https://thingspace.verizon.com/api/subsc/v1` | `serverOptions.subscriptionServer.production.baseUrl` | -| `subscriptionServer` | `staging` | `https://staging.thingspace.verizon.com/api/subsc/v1` | `serverOptions.subscriptionServer.staging.baseUrl` | -| `subscriptionServer` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/subsc/v1` | `serverOptions.subscriptionServer.dev.baseUrl` | -| `subscriptionServer` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/subsc/v1` | `serverOptions.subscriptionServer.qa.baseUrl` | -| `subscriptionServer` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/subsc/v1` | `serverOptions.subscriptionServer.mockServerForLimitedAvailabilitySeeQuickStart.baseUrl` | -| `softwareManagementV1` | `production` *(default)* | `https://thingspace.verizon.com/api/fota/v1` | `serverOptions.softwareManagementV1.production.baseUrl` | -| `softwareManagementV1` | `staging` | `https://staging.thingspace.verizon.com/api/fota/v1` | `serverOptions.softwareManagementV1.staging.baseUrl` | -| `softwareManagementV1` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/fota/v1` | `serverOptions.softwareManagementV1.dev.baseUrl` | -| `softwareManagementV1` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/fota/v1` | `serverOptions.softwareManagementV1.qa.baseUrl` | -| `softwareManagementV1` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/fota/v1` | `serverOptions.softwareManagementV1.mockServerForLimitedAvailabilitySeeQuickStart.baseUrl` | -| `softwareManagementV2` | `production` *(default)* | `https://thingspace.verizon.com/api/fota/v2` | `serverOptions.softwareManagementV2.production.baseUrl` | -| `softwareManagementV2` | `staging` | `https://staging.thingspace.verizon.com/api/fota/v2` | `serverOptions.softwareManagementV2.staging.baseUrl` | -| `softwareManagementV2` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/fota/v2` | `serverOptions.softwareManagementV2.dev.baseUrl` | -| `softwareManagementV2` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/fota/v2` | `serverOptions.softwareManagementV2.qa.baseUrl` | -| `softwareManagementV2` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/fota/v2` | `serverOptions.softwareManagementV2.mockServerForLimitedAvailabilitySeeQuickStart.baseUrl` | -| `softwareManagementV3` | `production` *(default)* | `https://thingspace.verizon.com/api/fota/v3` | `serverOptions.softwareManagementV3.production.baseUrl` | -| `softwareManagementV3` | `staging` | `https://staging.thingspace.verizon.com/api/fota/v3` | `serverOptions.softwareManagementV3.staging.baseUrl` | -| `softwareManagementV3` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/fota/v3` | `serverOptions.softwareManagementV3.dev.baseUrl` | -| `softwareManagementV3` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/fota/v3` | `serverOptions.softwareManagementV3.qa.baseUrl` | -| `softwareManagementV3` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/fota/v3` | `serverOptions.softwareManagementV3.mockServerForLimitedAvailabilitySeeQuickStart.baseUrl` | -| `deviceDiagnostics` | `production` *(default)* | `https://thingspace.verizon.com/api/diagnostics/v1` | `serverOptions.deviceDiagnostics.production.baseUrl` | -| `deviceDiagnostics` | `staging` | `https://staging.thingspace.verizon.com/api/diagnostics/v1` | `serverOptions.deviceDiagnostics.staging.baseUrl` | -| `deviceDiagnostics` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/diagnostics/v1` | `serverOptions.deviceDiagnostics.dev.baseUrl` | -| `deviceDiagnostics` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/diagnostics/v1` | `serverOptions.deviceDiagnostics.qa.baseUrl` | -| `deviceDiagnostics` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/diagnostics/v1` | `serverOptions.deviceDiagnostics.mockServerForLimitedAvailabilitySeeQuickStart.baseUrl` | -| `cloudConnector` | `production` *(default)* | `https://thingspace.verizon.com/api/cc/v1` | `serverOptions.cloudConnector.production.baseUrl` | -| `cloudConnector` | `staging` | `https://staging.thingspace.verizon.com/api/cc/v1` | `serverOptions.cloudConnector.staging.baseUrl` | -| `cloudConnector` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/cc/v1` | `serverOptions.cloudConnector.dev.baseUrl` | -| `cloudConnector` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/cc/v1` | `serverOptions.cloudConnector.qa.baseUrl` | -| `cloudConnector` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/cc/v1` | `serverOptions.cloudConnector.mockServerForLimitedAvailabilitySeeQuickStart.baseUrl` | -| `hyperPreciseLocation` | `production` *(default)* | `https://thingspace.verizon.com/api/hyper-precise/v1` | `serverOptions.hyperPreciseLocation.production.baseUrl` | -| `hyperPreciseLocation` | `staging` | `https://staging.thingspace.verizon.com/api/hyper-precise/v1` | `serverOptions.hyperPreciseLocation.staging.baseUrl` | -| `hyperPreciseLocation` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/hyper-precise/v1` | `serverOptions.hyperPreciseLocation.dev.baseUrl` | -| `hyperPreciseLocation` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/hyper-precise/v1` | `serverOptions.hyperPreciseLocation.qa.baseUrl` | -| `hyperPreciseLocation` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/hyper-precise/v1` | `serverOptions.hyperPreciseLocation.mockServerForLimitedAvailabilitySeeQuickStart.baseUrl` | -| `services` | `production` *(default)* | `https://5gedge.verizon.com/api/mec/services` | `serverOptions.services.production.baseUrl` | -| `services` | `staging` | `https://staging.5gedge.verizon.com/api/mec/services` | `serverOptions.services.staging.baseUrl` | -| `services` | `dev` | `https://devmanagement-staging.5gedge.verizon.com:80/mec/services` | `serverOptions.services.dev.baseUrl` | -| `services` | `qa` | `https://tsd-nginx-qa-us-east-1.5gedge.verizon.com/api/mec/services` | `serverOptions.services.qa.baseUrl` | -| `services` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/mec/services` | `serverOptions.services.mockServerForLimitedAvailabilitySeeQuickStart.baseUrl` | -| `qualityOfService` | `production` *(default)* | `https://thingspace.verizon.com/api/m2m/v1/devices` | `serverOptions.qualityOfService.production.baseUrl` | -| `qualityOfService` | `staging` | `https://staging.thingspace.verizon.com/api/m2m/v1/devices` | `serverOptions.qualityOfService.staging.baseUrl` | -| `qualityOfService` | `dev` | `https://devmanagement-staging.thingspace.verizon.com/api/m2m/v1/devices` | `serverOptions.qualityOfService.dev.baseUrl` | -| `qualityOfService` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/m2m/v1/devices` | `serverOptions.qualityOfService.qa.baseUrl` | -| `qualityOfService` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/m2m/v1/devices` | `serverOptions.qualityOfService.mockServerForLimitedAvailabilitySeeQuickStart.baseUrl` | +| `hyperPreciseCredentials` | `production` *(default)* | `https://thingspace.verizon.com/api/auth/v1` | `serverOptions.hyperPreciseCredentials.baseUrl` | +| `hyperPreciseCredentials` | `staging` | `https://staging.thingspace.verizon.com/api/auth/v1` | `serverOptions.hyperPreciseCredentials.baseUrl` | +| `hyperPreciseCredentials` | `dev` | `https://staging.thingspace.verizon.com/api/auth/v1` | `serverOptions.hyperPreciseCredentials.baseUrl` | +| `hyperPreciseCredentials` | `qa` | `https://thingspace.verizon.com/api/auth/v1` | `serverOptions.hyperPreciseCredentials.baseUrl` | +| `hyperPreciseCredentials` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://staging.thingspace.verizon.com/api/auth/v1` | `serverOptions.hyperPreciseCredentials.baseUrl` | +| `impServer` | `production` *(default)* | `https://imp.thingspace.verizon.com` | `serverOptions.impServer.baseUrl` | +| `impServer` | `staging` | `https://imp-staging.thingspace.verizon.com` | `serverOptions.impServer.baseUrl` | +| `impServer` | `dev` | `https://devmanagement-staging.imp.thingspace.verizon.com` | `serverOptions.impServer.baseUrl` | +| `impServer` | `qa` | `https://tsd-nginx-qa-us-east-1.imp.thingspace.verizon.com` | `serverOptions.impServer.baseUrl` | +| `impServer` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com` | `serverOptions.impServer.baseUrl` | +| `thingspace` | `production` *(default)* | `https://thingspace.verizon.com/api` | `serverOptions.thingspace.baseUrl` | +| `thingspace` | `staging` | `https://staging.thingspace.verizon.com/api` | `serverOptions.thingspace.baseUrl` | +| `thingspace` | `dev` | `https://devmanagement-staging.thingspace.verizon.com/api` | `serverOptions.thingspace.baseUrl` | +| `thingspace` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api` | `serverOptions.thingspace.baseUrl` | +| `thingspace` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api` | `serverOptions.thingspace.baseUrl` | +| `oAuthServer` | `production` *(default)* | `https://thingspace.verizon.com/api/ts/v1` | `serverOptions.oAuthServer.baseUrl` | +| `oAuthServer` | `staging` | `https://staging.thingspace.verizon.com/api/ts/v1` | `serverOptions.oAuthServer.baseUrl` | +| `oAuthServer` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/ts/v1` | `serverOptions.oAuthServer.baseUrl` | +| `oAuthServer` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/ts/v1` | `serverOptions.oAuthServer.baseUrl` | +| `oAuthServer` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/ts/v1` | `serverOptions.oAuthServer.baseUrl` | +| `m2M` | `production` *(default)* | `https://thingspace.verizon.com/api/m2m` | `serverOptions.m2M.baseUrl` | +| `m2M` | `staging` | `https://staging.thingspace.verizon.com/api/m2m` | `serverOptions.m2M.baseUrl` | +| `m2M` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/m2m` | `serverOptions.m2M.baseUrl` | +| `m2M` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/m2m` | `serverOptions.m2M.baseUrl` | +| `m2M` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/m2m` | `serverOptions.m2M.baseUrl` | +| `deviceLocation` | `production` *(default)* | `https://thingspace.verizon.com/api/loc/v1` | `serverOptions.deviceLocation.baseUrl` | +| `deviceLocation` | `staging` | `https://staging.thingspace.verizon.com/api/loc/v1` | `serverOptions.deviceLocation.baseUrl` | +| `deviceLocation` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/loc/v1` | `serverOptions.deviceLocation.baseUrl` | +| `deviceLocation` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/loc/v1` | `serverOptions.deviceLocation.baseUrl` | +| `deviceLocation` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/loc/v1` | `serverOptions.deviceLocation.baseUrl` | +| `subscriptionServer` | `production` *(default)* | `https://thingspace.verizon.com/api/subsc/v1` | `serverOptions.subscriptionServer.baseUrl` | +| `subscriptionServer` | `staging` | `https://staging.thingspace.verizon.com/api/subsc/v1` | `serverOptions.subscriptionServer.baseUrl` | +| `subscriptionServer` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/subsc/v1` | `serverOptions.subscriptionServer.baseUrl` | +| `subscriptionServer` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/subsc/v1` | `serverOptions.subscriptionServer.baseUrl` | +| `subscriptionServer` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/subsc/v1` | `serverOptions.subscriptionServer.baseUrl` | +| `softwareManagementV1` | `production` *(default)* | `https://thingspace.verizon.com/api/fota/v1` | `serverOptions.softwareManagementV1.baseUrl` | +| `softwareManagementV1` | `staging` | `https://staging.thingspace.verizon.com/api/fota/v1` | `serverOptions.softwareManagementV1.baseUrl` | +| `softwareManagementV1` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/fota/v1` | `serverOptions.softwareManagementV1.baseUrl` | +| `softwareManagementV1` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/fota/v1` | `serverOptions.softwareManagementV1.baseUrl` | +| `softwareManagementV1` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/fota/v1` | `serverOptions.softwareManagementV1.baseUrl` | +| `softwareManagementV2` | `production` *(default)* | `https://thingspace.verizon.com/api/fota/v2` | `serverOptions.softwareManagementV2.baseUrl` | +| `softwareManagementV2` | `staging` | `https://staging.thingspace.verizon.com/api/fota/v2` | `serverOptions.softwareManagementV2.baseUrl` | +| `softwareManagementV2` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/fota/v2` | `serverOptions.softwareManagementV2.baseUrl` | +| `softwareManagementV2` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/fota/v2` | `serverOptions.softwareManagementV2.baseUrl` | +| `softwareManagementV2` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/fota/v2` | `serverOptions.softwareManagementV2.baseUrl` | +| `softwareManagementV3` | `production` *(default)* | `https://thingspace.verizon.com/api/fota/v3` | `serverOptions.softwareManagementV3.baseUrl` | +| `softwareManagementV3` | `staging` | `https://staging.thingspace.verizon.com/api/fota/v3` | `serverOptions.softwareManagementV3.baseUrl` | +| `softwareManagementV3` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/fota/v3` | `serverOptions.softwareManagementV3.baseUrl` | +| `softwareManagementV3` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/fota/v3` | `serverOptions.softwareManagementV3.baseUrl` | +| `softwareManagementV3` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/fota/v3` | `serverOptions.softwareManagementV3.baseUrl` | +| `deviceDiagnostics` | `production` *(default)* | `https://thingspace.verizon.com/api/diagnostics/v1` | `serverOptions.deviceDiagnostics.baseUrl` | +| `deviceDiagnostics` | `staging` | `https://staging.thingspace.verizon.com/api/diagnostics/v1` | `serverOptions.deviceDiagnostics.baseUrl` | +| `deviceDiagnostics` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/diagnostics/v1` | `serverOptions.deviceDiagnostics.baseUrl` | +| `deviceDiagnostics` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/diagnostics/v1` | `serverOptions.deviceDiagnostics.baseUrl` | +| `deviceDiagnostics` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/diagnostics/v1` | `serverOptions.deviceDiagnostics.baseUrl` | +| `cloudConnector` | `production` *(default)* | `https://thingspace.verizon.com/api/cc/v1` | `serverOptions.cloudConnector.baseUrl` | +| `cloudConnector` | `staging` | `https://staging.thingspace.verizon.com/api/cc/v1` | `serverOptions.cloudConnector.baseUrl` | +| `cloudConnector` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/cc/v1` | `serverOptions.cloudConnector.baseUrl` | +| `cloudConnector` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/cc/v1` | `serverOptions.cloudConnector.baseUrl` | +| `cloudConnector` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/cc/v1` | `serverOptions.cloudConnector.baseUrl` | +| `hyperPreciseLocation` | `production` *(default)* | `https://thingspace.verizon.com/api/hyper-precise/v1` | `serverOptions.hyperPreciseLocation.baseUrl` | +| `hyperPreciseLocation` | `staging` | `https://staging.thingspace.verizon.com/api/hyper-precise/v1` | `serverOptions.hyperPreciseLocation.baseUrl` | +| `hyperPreciseLocation` | `dev` | `https://devmanagement-staging.thingspace.verizon.com:80/hyper-precise/v1` | `serverOptions.hyperPreciseLocation.baseUrl` | +| `hyperPreciseLocation` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/hyper-precise/v1` | `serverOptions.hyperPreciseLocation.baseUrl` | +| `hyperPreciseLocation` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/hyper-precise/v1` | `serverOptions.hyperPreciseLocation.baseUrl` | +| `services` | `production` *(default)* | `https://5gedge.verizon.com/api/mec/services` | `serverOptions.services.baseUrl` | +| `services` | `staging` | `https://staging.5gedge.verizon.com/api/mec/services` | `serverOptions.services.baseUrl` | +| `services` | `dev` | `https://devmanagement-staging.5gedge.verizon.com:80/mec/services` | `serverOptions.services.baseUrl` | +| `services` | `qa` | `https://tsd-nginx-qa-us-east-1.5gedge.verizon.com/api/mec/services` | `serverOptions.services.baseUrl` | +| `services` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/mec/services` | `serverOptions.services.baseUrl` | +| `qualityOfService` | `production` *(default)* | `https://thingspace.verizon.com/api/m2m/v1/devices` | `serverOptions.qualityOfService.baseUrl` | +| `qualityOfService` | `staging` | `https://staging.thingspace.verizon.com/api/m2m/v1/devices` | `serverOptions.qualityOfService.baseUrl` | +| `qualityOfService` | `dev` | `https://devmanagement-staging.thingspace.verizon.com/api/m2m/v1/devices` | `serverOptions.qualityOfService.baseUrl` | +| `qualityOfService` | `qa` | `https://tsd-nginx-qa-us-east-1.thingspace.verizon.com/api/m2m/v1/devices` | `serverOptions.qualityOfService.baseUrl` | +| `qualityOfService` | `mockServerForLimitedAvailabilitySeeQuickStart` | `https://mock-staging.thingspace.verizon.com/api/m2m/v1/devices` | `serverOptions.qualityOfService.baseUrl` | Consequences to state on every contract sheet that touches configuration: - Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `hyperPreciseCredentials`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. +- An environment value the SDK does not know throws `ConfigurationError` **from the constructor** — every server group is resolved once, as the client is built — so no operation method throws synchronously. ## Auth pattern (4 schemes) @@ -179,9 +181,9 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. -**OAuth 2 fetches and caches its own token.** The token request goes through **the same client** — same `timeout`, same `fetch` — sends a form-urlencoded body, and decodes the response against a schema rather than casting it. A token is cached until shortly before it expires; a response with no `expires_in` is treated as never expiring (RFC 6749 §5.1); concurrent callers share one in-flight fetch. **A refused token endpoint rejects with `AuthError`**, wrapping the underlying `ResponseError` as `cause` — so a bad secret never looks like the business call failing, and `AuthError` is *not* a `ResponseError`. +**OAuth 2 fetches and caches its own token.** The token request goes through **the same client** — same `fetch`, same `retry` policy — sends a form-urlencoded body, and decodes the response against a schema rather than casting it. A token is cached until shortly before it expires; a response with no `expires_in` is treated as never expiring (RFC 6749 §5.1); concurrent callers share one in-flight fetch. **A refused token endpoint rejects with `AuthError`**, wrapping the underlying `ApiError` as `cause` — so a bad secret never looks like the business call failing, and `AuthError` is *not* an `ApiError`. The token request is itself a `POST` with its own timeout, retried only when `httpMethodsToRetry` names `POST`. | Flow | Token endpoint | Client credentials travel | | --- | --- | --- | @@ -289,7 +291,7 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | `client.sensorInsightsDeviceProfile` | `SensorInsightsDeviceProfile` | 4 | `createAProfile` · `deleteAProfile` · `queryAProfile` · `updateAProfile` | | `client.sensorInsightsSmartAlertMetrics` | `SensorInsightsSmartAlertMetrics` | 1 | `sensorinsightsmetricsquery` | -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** 16 operations take `RequestParams` instead: `addDevices`, `deviceUpload`, `createDeviceGroup`, `registerCallback`, `updateTrigger`, `createTarget`, `deleteTarget`, `queryTarget`, `createSubscription`, `deleteSubscription`, `querySubscription`, `searchDeviceEventHistory`, `changePwnDeviceIPaddress`, `changePwnDeviceProfile`, `changePwnDeviceStateActivate`, `changePwnDeviceStateDeactivate`. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -297,7 +299,7 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: @@ -306,11 +308,11 @@ Locate the installed package before you rely on a lookup: node -e "console.log(require.resolve('verizon/package.json'))" ``` -Failing that it is at `node_modules/verizon/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/verizon/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/verizon/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -318,7 +320,7 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/verizon/`: @@ -328,13 +330,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -348,17 +352,23 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `VERIZON_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new VerizonClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters and the OAuth 2 token cache live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) -2. **Authentication** — load **typescript-authentication** before you set credentials. The 4 schemes are `thingspaceOauth`, `vzM2MToken`, `sessionToken`, `thingspaceOauth1` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; the token is fetched lazily and cached on the client; a **failed token fetch** raises `AuthError`, which is not a `ResponseError` and **bypasses `.asApiResult()` entirely**; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new VerizonClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters and the OAuth 2 token cache live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) +2. **Authentication** — load **typescript-authentication** before you set credentials. The 4 schemes are `thingspaceOauth`, `vzM2MToken`, `sessionToken`, `thingspaceOauth1` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; the token is fetched lazily and cached on the client; a **failed token fetch** raises `AuthError`, which is not an `ApiError` and **bypasses `.asApiResult()` entirely**; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **18 operations resolve to `undefined`**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `VerizonError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `VerizonError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `VERIZON_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK @@ -367,10 +377,12 @@ Beyond the usual signatures and model members, a contract sheet for the Verizon 1. **Which host each deployment talks to**, and where that is set. The members are `ServerEnvironment.Production`, `ServerEnvironment.Staging`, `ServerEnvironment.Dev`, `ServerEnvironment.Qa`, `ServerEnvironment.MockServerForLimitedAvailabilitySeeQuickStart`, defaulting to `ServerEnvironment.Production` when the field is unset. 2. **18 operations resolve to `undefined`** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet — 16 operations take `RequestParams`, not `Request`. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 293 of 314 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 293 of 314 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/verizon/skills/typescript/typescript-integrate-verizon/SKILL.md b/plugins/verizon/skills/typescript/typescript-integrate-verizon/SKILL.md index 21922a0bd..39671fbbb 100644 --- a/plugins/verizon/skills/typescript/typescript-integrate-verizon/SKILL.md +++ b/plugins/verizon/skills/typescript/typescript-integrate-verizon/SKILL.md @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/verizon/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('verizon/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('verizon/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. @@ -23,7 +23,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/verizon-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/verizon-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** @@ -40,11 +40,85 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** installing `verizon` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. @@ -63,8 +137,10 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from the **18 operations that resolve to `undefined`** (every operation whose success declares no body — the map's **Returns** bullet names them). `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `VerizonError` family, told apart by `err.kind` — and an unknown `serverEnvironment` throws `ConfigurationError` out of the client constructor, before any call exists. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `VERIZON_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions @@ -74,7 +150,7 @@ A standalone Verizon question with no code change: look it up in `typescript-get - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. - **Never write any Verizon or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. - **Don't web-search Verizon topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Verizon docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. diff --git a/plugins/verizon/skills/typescript/typescript-models/SKILL.md b/plugins/verizon/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/verizon/skills/typescript/typescript-models/SKILL.md +++ b/plugins/verizon/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/verizon/skills/typescript/typescript-models/reference.md b/plugins/verizon/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/verizon/skills/typescript/typescript-models/reference.md +++ b/plugins/verizon/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/verizon/skills/typescript/typescript-testing/SKILL.md b/plugins/verizon/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/verizon/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/verizon/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/vimeo/.claude-plugin/plugin.json b/plugins/vimeo/.claude-plugin/plugin.json index bba67bc55..ee7341524 100644 --- a/plugins/vimeo/.claude-plugin/plugin.json +++ b/plugins/vimeo/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "vimeo", "displayName": "Vimeo API", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Vimeo API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/vimeo/.cursor-plugin/plugin.json b/plugins/vimeo/.cursor-plugin/plugin.json index bba67bc55..ee7341524 100644 --- a/plugins/vimeo/.cursor-plugin/plugin.json +++ b/plugins/vimeo/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "vimeo", "displayName": "Vimeo API", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Vimeo API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/vimeo/plugin.json b/plugins/vimeo/plugin.json index bba67bc55..ee7341524 100644 --- a/plugins/vimeo/plugin.json +++ b/plugins/vimeo/plugin.json @@ -1,7 +1,7 @@ { "name": "vimeo", "displayName": "Vimeo API", - "version": "0.2.3", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Vimeo API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/vimeo/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/vimeo/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..55960fb35 100644 --- a/plugins/vimeo/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/vimeo/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,67 +1,36 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Vimeo API .NET SDK in C# — this API authenticates with a Bearer token, OAuth 2.0 client credentials and the OAuth 2.0 authorization-code grant, and this skill gives the credential each scheme takes, the VimeoApiClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Vimeo API .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with a Bearer token, OAuth 2.0 client credentials and the OAuth 2.0 authorization-code grant. APIMatic surfaces each scheme as a **nullable credentials property** on `VimeoApiClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) - -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). - -## Basic auth - -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` +| Scheme | Property on `VimeoApiClientOptions` | Credential | +| --- | --- | --- | +| Bearer token | `Bearer` | `string` | +| OAuth 2.0 client credentials | `Oauth2ClientCredentials` | `OAuth2ClientCredentials` | +| OAuth 2.0 authorization code | `Oauth2AuthorizationCode` | `OAuth2AuthorizationCodeCredentials` | -Sends `Authorization: Basic base64(username:password)`. +**That table is the entire authentication surface of this SDK.** `VimeoApi.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. ## Bearer token -Set the configured token property on the options class to your access-token string: +`options.Bearer` is a plain `string` — assign the access token itself, with no `Bearer` prefix, which the SDK adds: ```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; +options.Bearer = "ACCESS_TOKEN"; ``` Sends `Authorization: Bearer ACCESS_TOKEN`. -## API key (header, query, or cookie) - -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: - -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` - ## OAuth 2.0 — client credentials (machine-to-machine) ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; +using VimeoApi.Core.Authentication.OAuth2.ClientCredentials; -options.{OAuthProperty} = new OAuth2ClientCredentials +options.Oauth2ClientCredentials = new OAuth2ClientCredentials { ClientId = "...", ClientSecret = "...", @@ -69,18 +38,17 @@ options.{OAuthProperty} = new OAuth2ClientCredentials }; ``` -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. +The SDK fetches the token from `https://api.vimeo.com/oauth/authorize/client` and caches it, acquiring a fresh one when it expires; on a `401` it invalidates the cached token and re-acquires. The token request carries the client id and secret as HTTP Basic credentials, so `ClientSecret` is required. ## OAuth 2.0 — authorization code (3-legged, with PKCE) ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; +using VimeoApi.Core.Authentication.OAuth2.AuthorizationCode; -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials +options.Oauth2AuthorizationCode = new OAuth2AuthorizationCodeCredentials { ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE + ClientSecret = "...", RedirectUri = "https://app.example.com/callback", Scope = "...", // optional State = "...", // optional CSRF token @@ -94,152 +62,92 @@ options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials }; ``` -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. +The SDK sends the user to `https://api.vimeo.com/oauth/authorize`, exchanges the returned code for a token at `https://api.vimeo.com/oauth/access_token`, and refreshes it when it expires; if the refresh fails, it invokes `PromptForAuthorizationCode` again to re-authorize. -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: +`PromptForAuthorizationCode` runs inside the call that needs a new token, under the scheme's fetch lock, so it must not make an SDK call that uses this scheme. It counts against the per-attempt `options.Retry.Timeout`, 100 s by default, and when the call is retryable (by default a `GET`, `HEAD`, `PUT` or `OPTIONS`) a timed-out attempt is retried and prompts again. A prompt that throws anything but `OperationCanceledException` fails the call with `AuthSchemeException`. `State` is sent as you set it and never checked, so your redirect handler must verify it. -| PKCE | token request built | no secret ⇒ | -| --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | +⚠ **`ClientSecret` is required here — PKCE does not make it optional.** This SDK builds the token request with `OAuth2AuthorizationCodeStrategy.ForBasicAuthRequest`, which sends the client id and secret as HTTP Basic credentials on the exchange. Omit it and the call fails with an `AuthSchemeException` wrapping an `InvalidOperationException`, at one of two very different moments: -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. +- **PKCE disabled** (`Pkce = null`) — throws *before* `PromptForAuthorizationCode` runs. +- **PKCE enabled** (the default `S256`) — throws *after* the prompt has already run. The user completes a full browser round-trip, and only then does the exchange fail. This is the expensive one. -## OAuth 2.0 — resource owner password +The public-client carve-out — a secret-less PKCE flow — belongs to the form-body token request, which this SDK does not use. Do not plan around it here. -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; +## Token caching & refresh -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; -``` +- Tokens are cached in-memory, **per client instance**, and reused until **30s** before expiry. +- **Only the authorization-code grant refreshes.** It is the one grant wired to `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. The other grants above re-run in full when the token expires — and a `refresh_token` in their token response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do not infer refresh behaviour from what the provider returns. +- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: a `401` there is an interactive re-authorization, not a silent refresh. **Plan for that in a non-interactive host** — a background worker will block on a prompt nobody can answer. +- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of a fresh token. -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. +⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** `expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to "not expired" when it is absent — so the first token is cached for the life of the client and the only thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. ### Supplying your own token source -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. +The token strategy is a public extension point: `VimeoApiClientOptions` exposes a strategy property beside each credentials property, and the generated client falls back to the built-in strategy only when you leave it null. The strategy is used only when the credentials property beside it is also set, and `GetToken` receives that object. + +| Grant | Strategy property on `VimeoApiClientOptions` | Type | +| --- | --- | --- | +| Client credentials | `Oauth2ClientCredentialsTokenStrategy` | `IOAuth2TokenStrategy` | +| Authorization code | `Oauth2AuthorizationCodeTokenStrategy` | `IOAuth2RefreshableTokenStrategy` | ```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) +options.Oauth2ClientCredentials = new OAuth2ClientCredentials { /* its required members */ }; +options.Oauth2ClientCredentialsTokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) ``` -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: - -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). +Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test double. The per-client in-memory cache above still wraps whatever you return. -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. +## Combining the schemes -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. +This SDK generates more than one credentials property, and its operations compose them. You configure the composition by setting the relevant properties on `VimeoApiClientOptions` — the generated client wires the rest. -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +**OR (`AuthSchemeAny`) — alternatives, tried in order.** Schemes with **no credentials configured are skipped, not tried**; the first configured scheme that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails. Configure exactly the one you intend to use — an extra credential set "just in case" changes which scheme authenticates the call. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `VimeoApiClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("VimeoApiClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.Bearer)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "VimeoApiClient:Bearer is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"VimeoApiClient:Bearer is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. + +Check every value **the scheme you chose** needs. The rows below are not one checklist: the schemes above are alternatives, so a row belonging to a scheme you are not using needs no value at all, and demanding one at startup blocks a boot that would have worked. Validate the rows whose property you set, and no others: -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +| Value to validate | Feeds `VimeoApiClientOptions` property | +| --- | --- | +| `Bearer` | `Bearer` | +| `ClientId` | `Oauth2ClientCredentials` | +| `ClientSecret` | `Oauth2ClientCredentials` | +| `ClientId` | `Oauth2AuthorizationCode` | +| `ClientSecret` | `Oauth2AuthorizationCode` | +| `RedirectUri` | `Oauth2AuthorizationCode` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddVimeoApiClient(options => ...)` callback when registering via DI. `VimeoApiClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddVimeoApiClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/vimeo/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/vimeo/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/vimeo/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/vimeo/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/vimeo/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/vimeo/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/vimeo/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/vimeo/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/vimeo/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/vimeo/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/vimeo/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/vimeo/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/vimeo/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/vimeo/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/vimeo/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/vimeo/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException
` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/vimeo/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/vimeo/skills/dotnet/dotnet-getting-started/SKILL.md index 6b9e470a2..1b4b1ddbd 100644 --- a/plugins/vimeo/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/vimeo/skills/dotnet/dotnet-getting-started/SKILL.md @@ -1,17 +1,17 @@ --- name: "dotnet-getting-started" -description: "Vimeo API .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-vimeo is the workflow that uses it." +description: "Vimeo API .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-vimeo-api is the workflow that uses it." --- # Getting started with the Vimeo API .NET SDK -> **Who this skill is for.** This is the **map layer**: load it to ground every Vimeo API SDK fact. `dotnet-integrate-vimeo` is the workflow that drives it — you collect the contracts for the work in scope into `vimeo-api-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. +> **Who this skill is for.** This is the **map layer**: load it to ground every Vimeo API SDK fact. `dotnet-integrate-vimeo-api` is the workflow that drives it — you collect the contracts for the work in scope into `vimeo-api-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated .NET SDK (auth, calling endpoints, models, error handling, retries, testing), see the companion API-agnostic skills: `dotnet-client-initialization`, `dotnet-authentication`, `dotnet-calling-endpoints`, `dotnet-models`, `dotnet-error-handling`, `dotnet-configuration-resilience`, `dotnet-testing`. **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity @@ -26,9 +26,9 @@ This is the **SDK-specific** entry point. For general patterns that apply to any | Auth | **Bearer** token — set `Bearer`
**OAuth 2.0** authorization code — set `Oauth2AuthorizationCode`
**OAuth 2.0** client credentials — set `Oauth2ClientCredentials` | | Environments | `ServerEnvironment.Production` *(default)* → `https://api.vimeo.com` | | Log environment variable | `VIMEOAPICLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | -The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-vimeo` makes that binding); the **Integration workflow** below says which governs which step. +The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-vimeo-api` makes that binding); the **Integration workflow** below says which governs which step. ## Namespaces (using-directives) @@ -44,22 +44,24 @@ Models, enums, union types and the generated error types each sit in their own c ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Vimeo API, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/VimeoApi/Models/`, `src/VimeoApi/Errors/` and `src/VimeoApi/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/VimeoApi/Models/`, enums `src/VimeoApi/Models/Enums/`, typed errors `src/VimeoApi/Errors/`, request records `src/VimeoApi/Requests/`, or `src/VimeoApi/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: @@ -69,7 +71,7 @@ Keep lookups cheap — the rules that keep a session's context small: ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/vimeo-api-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/vimeo-api-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. **Clone once, up front**, into a fresh timestamped folder under `/vimeo-api-sdk-src/`, and reuse that folder for the rest of your session: @@ -87,30 +89,31 @@ git clone --filter=blob:none --branch main https://github.com/context-plugins/vi # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/vimeo-api-sdk-src/`, **never** in the project repo, and its path never goes into `vimeo-api-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `vimeo-api-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `VimeoApiClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/VimeoApi/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/VimeoApi/Models/` (+ `src/VimeoApi/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/VimeoApi/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/VimeoApi/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/VimeoApi/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/VimeoApi/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/VimeoApi/Servers/`, `src/VimeoApi/VimeoApiClient.cs`, `src/VimeoApi/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Vimeo API may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything @@ -126,17 +129,17 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/VimeoApi/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step -You loaded every companion the sheet named before starting (per `dotnet-integrate-vimeo`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: +You loaded every companion the sheet named before starting (per `dotnet-integrate-vimeo-api`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: 1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new VimeoApiClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) 2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `VimeoApiClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/vimeo/skills/dotnet/dotnet-integrate-vimeo/SKILL.md b/plugins/vimeo/skills/dotnet/dotnet-integrate-vimeo-api/SKILL.md similarity index 67% rename from plugins/vimeo/skills/dotnet/dotnet-integrate-vimeo/SKILL.md rename to plugins/vimeo/skills/dotnet/dotnet-integrate-vimeo-api/SKILL.md index f7c15221b..4a6fe68f5 100644 --- a/plugins/vimeo/skills/dotnet/dotnet-integrate-vimeo/SKILL.md +++ b/plugins/vimeo/skills/dotnet/dotnet-integrate-vimeo-api/SKILL.md @@ -1,125 +1,173 @@ ---- -name: "dotnet-integrate-vimeo" -description: "MANDATORY FIRST STEP for Vimeo API .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Vimeo API in C# — ApiAppsWebhooks, ApiInformationEssentials, AuthenticationExtrasAuthenticate, AuthenticationExtrasConvert, AuthenticationExtrasEssentials, AuthenticationExtrasExchange, CategoriesChannels, CategoriesEssentials, CategoriesGroups, CategoriesUsers, CategoriesVideos, ChannelsCategories, ChannelsEssentials, ChannelsModerators, ChannelsPrivateChannelMembers, ChannelsSubscriptionsAndSubscribers, ChannelsTags, ChannelsVideos, EmbedPresetsCustomLogos, EmbedPresetsEssentials, EmbedPresetsFolders, EmbedPresetsTimelineEvents, EmbedPresetsVideos, FoldersEssentials, FoldersItems, FoldersVideos, GroupsEssentials, GroupsSubscriptions, GroupsUsers, GroupsVideos, LikesEssentials, LiveAnalytics, LiveAudioTracks, LiveEmbedPrivacy, LiveEssentials, LiveEventM3U8Playback, LiveEventActivation, LiveEventAutomatedClosedCaptions, LiveEventDestinations, LiveEventEnd, LiveEventLowLatency, LiveEventSessions, LiveEventThumbnails, LiveEventVideos, LiveGraphics, LiveScenes, OnDemandBackgrounds, OnDemandEssentials, OnDemandGenres, OnDemandPosters, OnDemandPromotions, OnDemandPurchasesAndRentals, OnDemandRegions, OnDemandSeasons, OnDemandVideos, PaymentsEssentials, PortfoliosEssentials, PortfoliosVideos, SearchFederated, ShowcasesCustomShowcaseLogos, ShowcasesCustomShowcaseThumbnails, ShowcasesEssentials, ShowcasesShowcaseVideos, SubscriptionPlansEssentials, TagsEssentials, TeamsEssentials, TeamsMembers, TutorialEssentials, UsersAnalytics, UsersEssentials, UsersFeeds, UsersFollowers, UsersLms, UsersPictures, UsersSearch, UsersWatchHistory, VideosAi, VideosAnimatedThumbnails, VideosChapters, VideosContentRatings, VideosCreativeCommons, VideosCredits, VideosEmbedPrivacy, VideosEssentials, VideosFragments, VideosLanguages, VideosLiveM3U8Playback, VideosModeration, VideosNondestructiveTrimming, VideosRecommendations, VideosShowcases, VideosTags, VideosTextTracks, VideosThumbnails, VideosTranscripts, VideosUnlistedVideos, VideosUploads, VideosVersions, VideosVideoComments, WatchLaterQueueEssentials — or when a Vimeo API .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file vimeo-api-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." ---- - -# Vimeo API .NET SDK — Workflow (map + skills) - -You are the Vimeo API .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. - -**Scope guard:** the APIMatic-generated Vimeo API **.NET SDK** (root namespace `VimeoApi`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. - -## Grounding rules - -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `vimeo-api-plan.md` — the plan must stay portable. -- **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. -- **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. -- **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. -- **The sheet records Vimeo API's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. -- **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. - -## Workflow - -**If the user opens with a reported SDK error or unexpected Vimeo API behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: - -### Step 1 — Plan first: write `vimeo-api-plan.md` (always, for any implementation work) - -Your FIRST deliverable is `vimeo-api-plan.md` at `/vimeo-api-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. - -#### How to ground (map-first, one pass) - -1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. -3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. -4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. -5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. - -#### Prerequisites you can run alongside - -These need no SDK knowledge and touch no project file, so do them before or alongside the map work: - -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); -- the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; -- locating the SDK reference and its version in the project; -- credentials/environment verification (per the task's secret-handling rules); -- setting up your task tracking. - -Never use this prerequisite work as a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. - -#### HARD GATE - -**No project-file creation or edits until `vimeo-api-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. - -#### The `vimeo-api-plan.md` format (keep it tight — tables, not prose) - -1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. -3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. -6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. -7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. - -| # | Concern | The decision the plan must record | -| --- | --- | --- | -| 1 | **Credential fail-fast** | Where credentials are bound, and that the host refuses to start when one is missing or blank — rather than discovering it as a 401 on the first call in production. A multi-part credential needs **every** part checked, because a blank part is not a missing one. | -| 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | -| 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | -| 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | -| 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | -| 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | -| 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | - -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. - -Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. - -### Step 2 — Required reading (do this before you write any code) - -The contract sheet ends with a **REQUIRED READING** block, and its rows carry inline `MUST load ` pointers. **Load every `dotnet-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. If the sheet names none, load `dotnet-error-handling` anyway — every integration writes an error boundary. - -These are API-agnostic usage skills; loading them is not the same as reading the map. Contract *facts* still come only from the sheet or a map lookup. - -Before implementing, check the plan's **Assumptions & Blockers** section: - -- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `vimeo-api-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. -- Minor assumptions only → proceed. - -Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. - -### Step 3 — Implement from the contract sheet - -1. Read `vimeo-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. -2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. -3. After every change: `dotnet build`; fix non-SDK errors yourself. -4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `VimeoApi.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. - -### Step 4 — Fixing SDK errors (map-first, in place) - -A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `VimeoApi.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. - -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. -3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". -4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). -5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. -6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. - -If a fix corrects a row in `vimeo-api-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. - -### Step 5 — Pure questions - -A standalone Vimeo API question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. - -## Anti-patterns — never do these - -- **Never write a Vimeo API/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** -- **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. -- **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. -- **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search Vimeo API for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `VimeoApi` is what the generated source declares, not what public docs describe. -- **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. - +--- +name: "dotnet-integrate-vimeo-api" +description: "MANDATORY FIRST STEP for Vimeo API .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Vimeo API in C# — ApiAppsWebhooks, ApiInformationEssentials, AuthenticationExtrasAuthenticate, AuthenticationExtrasConvert, AuthenticationExtrasEssentials, AuthenticationExtrasExchange, CategoriesChannels, CategoriesEssentials, CategoriesGroups, CategoriesUsers, CategoriesVideos, ChannelsCategories, ChannelsEssentials, ChannelsModerators, ChannelsPrivateChannelMembers, ChannelsSubscriptionsAndSubscribers, ChannelsTags, ChannelsVideos, EmbedPresetsCustomLogos, EmbedPresetsEssentials, EmbedPresetsFolders, EmbedPresetsTimelineEvents, EmbedPresetsVideos, FoldersEssentials, FoldersItems, FoldersVideos, GroupsEssentials, GroupsSubscriptions, GroupsUsers, GroupsVideos, LikesEssentials, LiveAnalytics, LiveAudioTracks, LiveEmbedPrivacy, LiveEssentials, LiveEventM3U8Playback, LiveEventActivation, LiveEventAutomatedClosedCaptions, LiveEventDestinations, LiveEventEnd, LiveEventLowLatency, LiveEventSessions, LiveEventThumbnails, LiveEventVideos, LiveGraphics, LiveScenes, OnDemandBackgrounds, OnDemandEssentials, OnDemandGenres, OnDemandPosters, OnDemandPromotions, OnDemandPurchasesAndRentals, OnDemandRegions, OnDemandSeasons, OnDemandVideos, PaymentsEssentials, PortfoliosEssentials, PortfoliosVideos, SearchFederated, ShowcasesCustomShowcaseLogos, ShowcasesCustomShowcaseThumbnails, ShowcasesEssentials, ShowcasesShowcaseVideos, SubscriptionPlansEssentials, TagsEssentials, TeamsEssentials, TeamsMembers, TutorialEssentials, UsersAnalytics, UsersEssentials, UsersFeeds, UsersFollowers, UsersLms, UsersPictures, UsersSearch, UsersWatchHistory, VideosAi, VideosAnimatedThumbnails, VideosChapters, VideosContentRatings, VideosCreativeCommons, VideosCredits, VideosEmbedPrivacy, VideosEssentials, VideosFragments, VideosLanguages, VideosLiveM3U8Playback, VideosModeration, VideosNondestructiveTrimming, VideosRecommendations, VideosShowcases, VideosTags, VideosTextTracks, VideosThumbnails, VideosTranscripts, VideosUnlistedVideos, VideosUploads, VideosVersions, VideosVideoComments, WatchLaterQueueEssentials — or when a Vimeo API .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file vimeo-api-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." +--- + +# Vimeo API .NET SDK — Workflow (map + skills) + +You are the Vimeo API .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. + +**Scope guard:** the APIMatic-generated Vimeo API **.NET SDK** (root namespace `VimeoApi`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. + +## Grounding rules + +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `vimeo-api-plan.md` never carries its path — the plan must stay portable. +- **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. +- **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. +- **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. +- **The sheet records Vimeo API's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. +- **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. + +## Workflow + +**If the user opens with a reported SDK error or unexpected Vimeo API behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: + +### Step 1 — Plan first: write `vimeo-api-plan.md` (always, for any implementation work) + +Your FIRST deliverable is `vimeo-api-plan.md` at `/vimeo-api-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. + +#### How to ground (map-first, one pass) + +1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. +3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. +4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. +5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. + +#### Prerequisites you can run alongside + +These need no SDK knowledge and touch no project file, so do them before or alongside the map work: + +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; +- `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; +- locating the SDK reference and its version in the project; +- credentials/environment verification (per the task's secret-handling rules); +- setting up your task tracking. + +Never use this prerequisite work as a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. + +#### HARD GATE + +**No project-file creation or edits until `vimeo-api-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. + +#### The `vimeo-api-plan.md` format (keep it tight — tables, not prose) + +1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + +3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. +7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. + +| # | Concern | The decision the plan must record | +| --- | --- | --- | +| 1 | **Credential fail-fast** | Where credentials are bound, and that the host refuses to start when one is missing or blank — rather than discovering it as a 401 on the first call in production. A multi-part credential needs **every** part checked, because a blank part is not a missing one. | +| 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | +| 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | +| 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | +| 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | +| 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. + +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. + +Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. + +### Step 2 — Required reading (do this before you write any code) + +The contract sheet ends with a **REQUIRED READING** block, and its rows carry inline `MUST load ` pointers. **Load every `dotnet-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. If the sheet names none, load `dotnet-error-handling` anyway — every integration writes an error boundary. + +These are API-agnostic usage skills; loading them is not the same as reading the map. Contract *facts* still come only from the sheet or a map lookup. + +Before implementing, check the plan's **Assumptions & Blockers** section: + +- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `vimeo-api-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. +- Minor assumptions only → proceed. + +Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. + +### Step 3 — Implement from the contract sheet + +1. Read `vimeo-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. +2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. +3. After every change: `dotnet build`; fix non-SDK errors yourself. +4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `VimeoApi.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. + +### Step 4 — Fixing SDK errors (map-first, in place) + +A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `VimeoApi.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. + +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/VimeoApi/Models/{Type}.cs`, `src/VimeoApi/Models/Enums/{Type}.cs`, `src/VimeoApi/Errors/{Type}.cs`, `src/VimeoApi/Requests/{Type}.cs`, or `src/VimeoApi/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. +3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". +4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). +5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. +6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. + +If a fix corrects a row in `vimeo-api-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. + +### Step 5 — Pure questions + +A standalone Vimeo API question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. + +## Anti-patterns — never do these + +- **Never write a Vimeo API/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** +- **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. +- **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. +- **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. +- **Do not web-search Vimeo API for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `VimeoApi` is what the generated source declares, not what public docs describe. +- **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. + diff --git a/plugins/vimeo/skills/dotnet/dotnet-models/SKILL.md b/plugins/vimeo/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/vimeo/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/vimeo/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/vimeo/skills/dotnet/dotnet-models/reference.md b/plugins/vimeo/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/vimeo/skills/dotnet/dotnet-models/reference.md +++ b/plugins/vimeo/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/vimeo/skills/dotnet/dotnet-testing/SKILL.md b/plugins/vimeo/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/vimeo/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/vimeo/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/vimeo/skills/python/python-authentication/SKILL.md b/plugins/vimeo/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/vimeo/skills/python/python-authentication/SKILL.md +++ b/plugins/vimeo/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/vimeo/skills/python/python-calling-endpoints/SKILL.md b/plugins/vimeo/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/vimeo/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/vimeo/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/vimeo/skills/python/python-client-initialization/SKILL.md b/plugins/vimeo/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/vimeo/skills/python/python-client-initialization/SKILL.md +++ b/plugins/vimeo/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/vimeo/skills/python/python-configuration-resilience/SKILL.md b/plugins/vimeo/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/vimeo/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/vimeo/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/vimeo/skills/python/python-error-handling/SKILL.md b/plugins/vimeo/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/vimeo/skills/python/python-error-handling/SKILL.md +++ b/plugins/vimeo/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/vimeo/skills/python/python-event-streams/SKILL.md b/plugins/vimeo/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/vimeo/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/vimeo/skills/python/python-file-handling/SKILL.md b/plugins/vimeo/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/vimeo/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/vimeo/skills/python/python-getting-started/SKILL.md b/plugins/vimeo/skills/python/python-getting-started/SKILL.md index b3b3bc6ff..cbcce64b7 100644 --- a/plugins/vimeo/skills/python/python-getting-started/SKILL.md +++ b/plugins/vimeo/skills/python/python-getting-started/SKILL.md @@ -5,7 +5,7 @@ description: "Vimeo API Python SDK identity and lookup layer (Python only) — i # Getting started with the Vimeo API Python SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Vimeo API Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-vimeo` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Vimeo API Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-vimeo-api` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated Python SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `python-client-initialization`, `python-authentication`, `python-calling-endpoints`, `python-models`, `python-error-handling`, `python-configuration-resilience` and `python-testing`. @@ -25,27 +25,32 @@ Verified against `vimeo_api/` and `pyproject.toml` of the generated package at v | Version | `3.4.9` | | Sync client class | `VimeoApiClient` (alias `Client`) | | Async client class | `AsyncVimeoApiClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`) · `bearer` · `oauth2_authorization_code` · `oauth2_authorization_code_token_source` · `oauth2_client_credentials` · `oauth2_client_credentials_token_source`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Client construction | **keyword-only**: `base_url` · `timeout` (default `30.0`) · `retry_options` · `bearer` · `oauth2_authorization_code` · `oauth2_authorization_code_token_source` · `oauth2_client_credentials` · `oauth2_client_credentials_token_source`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | **Bearer** token — set `bearer` · **OAuth 2.0** authorization code — set `oauth2_authorization_code` · **OAuth 2.0** client credentials — set `oauth2_client_credentials` | | Environments | **no environment enum** — one `base_url` string, defaulting to `https://api.vimeo.com` | | Base-URL config | `ServerConfig` (`vimeo_api/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | -| Surface | 520 operations across 100 controllers · 784 models · 1 union · 270 enums · 411 per-operation error unions | +| Surface | 520 operations across 100 controllers · 784 models · 1 union · 281 enums · 411 per-operation error unions | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, import roots, the auth *pattern*, the base-URL knob), while the actual integration code comes from the companion skills. Load each one as you reach its step (see **Integration workflow** below) and confirm its types against the installed package. ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash pip install "vimeo-api @ git+https://github.com/context-plugins/vimeo-python-sdk.git@main" +python -c "import vimeo_api, pathlib; print(pathlib.Path(vimeo_api.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules @@ -243,15 +248,15 @@ The SDK map's controller table carries the same counts and links to a page per c The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `vimeo_api/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `vimeo_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `vimeo_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/vimeo-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. Every `Source` path on the map is relative to that SDK root, so `vimeo_api/models/api_app.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. @@ -271,7 +276,7 @@ Read the one module that owns the fact **inside the installed package**. Locate python -c "import vimeo_api, pathlib; print(pathlib.Path(vimeo_api.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/vimeo_api` on Windows, `.venv/lib/python3.*/site-packages/vimeo_api` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/vimeo_api` on Windows, `.venv/lib/python3.*/site-packages/vimeo_api` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -279,7 +284,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/vi | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -289,7 +294,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/vi | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | | An operation's error mapper (status → schema) | `errors/_error.py` | @@ -306,13 +311,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The three schemes are `bearer=`, `oauth2_authorization_code=` and `oauth2_client_credentials=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; the token is fetched lazily and cached by the client; and a **failed token fetch** raises something other than what a caller expects and **bypasses the non-raising response mode** entirely. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; **174 operations return `None`**, so `with_raw_response` is the only way to observe their status code; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`/`…OrInt`), so an unknown wire value passes through as a plain `str`/`int` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -328,8 +333,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 3. `AuthError` — 2 operations (`authentication_extras_convert.convert_access_token` · `authentication_extras_exchange.exchange_auth_code`); distinguishing members `error` · `error_description` 4. *(none)* — `api_apps_webhooks.get_webhooks` · `api_information_essentials.get_endpoints` · `categories_essentials.get_categories` · `categories_users.check_if_user_subscribed_to_category` · `categories_users.check_if_user_subscribed_to_category_alt1` · `categories_users.subscribe_to_category` · `categories_users.subscribe_to_category_alt1` · `categories_users.unsubscribe_from_category` · `categories_users.unsubscribe_from_category_alt1` · `channels_essentials.get_channel` · `channels_moderators.get_channel_moderator` · `channels_moderators.get_channel_moderators` · `embed_presets_essentials.create_embed_presets` · `embed_presets_essentials.create_embed_presets_alt1` · `embed_presets_essentials.get_embed_preset` · `embed_presets_essentials.get_embed_preset_alt1` · `embed_presets_essentials.get_embed_presets` · `embed_presets_essentials.get_embed_presets_alt1` · `embed_presets_videos.add_video_embed_preset` · `embed_presets_videos.get_embed_preset_videos` · `embed_presets_videos.get_embed_preset_videos_alt1` · `folders_items.get_folder_items` · `folders_items.get_folder_items2` · `likes_essentials.get_video_likes` · `likes_essentials.get_video_likes_alt1` · `live_essentials.get_live_event_occurrences` · `live_essentials.get_live_events` · `live_essentials.get_live_events_alt1` · `live_essentials.get_live_events_alt2` · `live_event_sessions.get_live_clip_ingest_status` · `live_event_sessions.get_live_ingest_status` · `on_demand_essentials.create_vod` · `on_demand_essentials.create_vod_alt1` · `on_demand_posters.edit_vod_poster` · `on_demand_posters.get_vod_poster` · `on_demand_regions.get_regions` · `on_demand_seasons.get_vod_season_videos` · `on_demand_videos.get_vod_video` · `on_demand_videos.get_vod_videos` · `payments_essentials.get_subscription_info` · `portfolios_essentials.get_portfolio` · `portfolios_essentials.get_portfolio_alt1` · `portfolios_essentials.get_portfolios` · `portfolios_essentials.get_portfolios_alt1` · `portfolios_videos.get_portfolio_video` · `portfolios_videos.get_portfolio_video_alt1` · `portfolios_videos.get_portfolio_videos` · `portfolios_videos.get_portfolio_videos_alt1` · `showcases_essentials.update_showcases` · `subscription_plans_essentials.get_plan` · `tags_essentials.get_tag` · `teams_members.get_team_information` · `teams_members.get_team_information2` · `teams_members.get_team_role_information` · `tutorial_essentials.developer_tutorial` · `users_analytics.get_user_analytics` · `users_analytics.get_user_analytics_alt1` · `users_essentials.get_user` · `users_essentials.get_user_alt1` · `users_followers.get_followers` · `users_followers.get_followers_alt1` · `users_followers.get_user_following` · `users_followers.get_user_following_alt1` · `users_followers.unfollow_user` · `users_followers.unfollow_user_alt1` · `users_lms.download_lms_package` · `users_pictures.create_picture` · `users_pictures.create_picture_alt1` · `users_pictures.delete_picture` · `users_pictures.delete_picture_alt1` · `users_pictures.edit_picture` · `users_pictures.edit_picture_alt1` · `users_pictures.get_picture` · `users_pictures.get_picture_alt1` · `users_pictures.get_pictures` · `users_pictures.get_pictures_alt1` · `users_watch_history.delete_from_watch_history` · `users_watch_history.delete_watch_history` · `videos_content_ratings.get_content_ratings` · `videos_creative_commons.get_cc_licenses` · `videos_credits.available_users` · `videos_credits.get_video_credits` · `videos_credits.get_video_credits_alt1` · `videos_essentials.get_videos` · `videos_essentials.get_videos_alt1` · `videos_fragments.delete_fragments` · `videos_fragments.get_fragments` · `videos_fragments.put_fragments` · `videos_languages.get_languages` · `videos_tags.get_video_tags` · `videos_thumbnails.create_video_thumbnail` · `videos_thumbnails.create_video_thumbnail_alt1` · `videos_thumbnails.delete_video_thumbnail` · `videos_thumbnails.edit_video_thumbnail` · `videos_thumbnails.get_video_thumbnail` · `videos_thumbnails.get_video_thumbnails` · `videos_thumbnails.get_video_thumbnails_alt1` · `videos_unlisted_videos.add_video_privacy_users` · `videos_unlisted_videos.add_video_privacy_users_alt1` · `videos_uploads.get_upload_attempt` · `videos_versions.get_audiotrack_downloads` · `videos_versions.get_version_thumbnail` · `videos_video_comments.get_comments` · `videos_video_comments.get_comments_alt1` · `watch_later_queue_essentials.add_video_to_watch_later` · `watch_later_queue_essentials.add_video_to_watch_later_alt1` · `watch_later_queue_essentials.delete_video_from_watch_later` · `watch_later_queue_essentials.delete_video_from_watch_later_alt1` · `watch_later_queue_essentials.get_watch_later_queue` · `watch_later_queue_essentials.get_watch_later_queue_alt1`: no typed arm, so `.error` is always `RawError` 5. So `isinstance(e.error, LegacyError)` matches only 237 of 520 operations. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 212 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which host the `base_url` selects**, because omitting it is silently the default. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 212 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which host the `base_url` selects**, because omitting it is silently the default. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/vimeo/skills/python/python-integrate-vimeo/SKILL.md b/plugins/vimeo/skills/python/python-integrate-vimeo-api/SKILL.md similarity index 81% rename from plugins/vimeo/skills/python/python-integrate-vimeo/SKILL.md rename to plugins/vimeo/skills/python/python-integrate-vimeo-api/SKILL.md index ad321305c..00bc60ad9 100644 --- a/plugins/vimeo/skills/python/python-integrate-vimeo/SKILL.md +++ b/plugins/vimeo/skills/python/python-integrate-vimeo-api/SKILL.md @@ -1,81 +1,97 @@ ---- -name: "python-integrate-vimeo" -description: "MANDATORY FIRST STEP for Vimeo API Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Vimeo API in Python — this method adds a webhook for the specified app, this method deletes the specified webhook, this method returns the specified webhook, this method updates the specified webhook, this method returns every available category, this method returns the specified category, this method creates a new channel, this method edits the specified channel — or when a Vimeo API Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." ---- - -# Vimeo API Python SDK — Integration workflow (lookup layer + contract sheet) - -You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. - -## Your lookup layer - -- **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. -- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import vimeo_api, pathlib; print(pathlib.Path(vimeo_api.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) -- **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. -- **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. - -**Scope guard:** the APIMatic-generated Vimeo API **Python SDK** (import root `vimeo_api`, distribution `vimeo-api`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. - -## Workflow - -**If the user opens with a reported SDK error or unexpected Vimeo API behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: - -### Step 1 — Plan first (always, for any implementation work) - -Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/vimeo-api-plan.md` — that absolute path, not a location you pick later. - -**Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - -- the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `vimeo-api` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; -- a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — including which host `base_url` will select, since omitting it is silent; -- **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; -- setting up your task tracking. - -Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. - -**HARD GATE — no project-file creation or edits until:** `vimeo-api-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. - -**What the gate does not cover:** creating or activating a virtual environment, installing `vimeo-api` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. - -### Step 1c — Required reading (do this before you write any code) - -End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. - -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `python-error-handling` always (every integration writes an error boundary); `python-client-initialization` before the client is constructed; `python-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes the transport. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. - -These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a module in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. - -Before implementing, check the plan's **Assumptions & Blockers** section: - -- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `vimeo-api-plan.md` in place. -- Minor assumptions only → proceed. - -Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. - -### Step 2 — Implement from the contract sheet - -1. Read `vimeo-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). -3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. -4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. -5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `vimeo_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. - -### Step 3 — Answering pure questions - -A standalone Vimeo API question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. - -## Anti-patterns — never do these - -- **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. -- **Never write a Vimeo API/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Don't web-search Vimeo API topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Vimeo API docs describe the REST API, not this SDK's generated surface, and the two disagree on names. -- **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. -- **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. -- **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. -- **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. - +--- +name: "python-integrate-vimeo-api" +description: "MANDATORY FIRST STEP for Vimeo API Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Vimeo API in Python — this method adds a webhook for the specified app, this method deletes the specified webhook, this method returns the specified webhook, this method updates the specified webhook, this method returns every available category, this method returns the specified category, this method creates a new channel, this method edits the specified channel — or when a Vimeo API Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." +--- + +# Vimeo API Python SDK — Integration workflow (lookup layer + contract sheet) + +You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. + +## Your lookup layer + +- **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. +- **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import vimeo_api, pathlib; print(pathlib.Path(vimeo_api.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. +- **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. + +**Scope guard:** the APIMatic-generated Vimeo API **Python SDK** (import root `vimeo_api`, distribution `vimeo-api`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. + +## Workflow + +**If the user opens with a reported SDK error or unexpected Vimeo API behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: + +### Step 1 — Plan first (always, for any implementation work) + +Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/vimeo-api-plan.md` — that absolute path, not a location you pick later. + +**Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** + +- the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; +- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `vimeo-api` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; +- a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; +- credentials/environment verification (per the task's secret-handling rules) — including which host `base_url` will select, since omitting it is silent; +- **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; +- setting up your task tracking. + +Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. + +**HARD GATE — no project-file creation or edits until:** `vimeo-api-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. + +**What the gate does not cover:** creating or activating a virtual environment, installing `vimeo-api` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +### Step 1c — Required reading (do this before you write any code) + +End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `python-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. + +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `python-error-handling` always (every integration writes an error boundary); `python-client-initialization` before the client is constructed; `python-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes the transport. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. + +These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a module in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. + +Before implementing, check the plan's **Assumptions & Blockers** section: + +- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `vimeo-api-plan.md` in place. +- Minor assumptions only → proceed. + +Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. + +### Step 2 — Implement from the contract sheet + +1. Read `vimeo-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. +2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). +3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. +4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. +5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `vimeo_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. + +### Step 3 — Answering pure questions + +A standalone Vimeo API question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. + +## Anti-patterns — never do these + +- **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. +- **Never write a Vimeo API/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** +- **Don't web-search Vimeo API topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Vimeo API docs describe the REST API, not this SDK's generated surface, and the two disagree on names. +- **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. +- **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. +- **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. +- **Don't create or edit project files before the HARD GATE** in Step 1 — plan and contract sheet first, code second. + diff --git a/plugins/vimeo/skills/python/python-models/SKILL.md b/plugins/vimeo/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/vimeo/skills/python/python-models/SKILL.md +++ b/plugins/vimeo/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/vimeo/skills/python/python-models/reference.md b/plugins/vimeo/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/vimeo/skills/python/python-models/reference.md +++ b/plugins/vimeo/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/vimeo/skills/python/python-testing/SKILL.md b/plugins/vimeo/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/vimeo/skills/python/python-testing/SKILL.md +++ b/plugins/vimeo/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/vimeo/skills/typescript/typescript-authentication/SKILL.md b/plugins/vimeo/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/vimeo/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/vimeo/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/vimeo/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/vimeo/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/vimeo/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/vimeo/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/vimeo/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/vimeo/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/vimeo/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/vimeo/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/vimeo/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/vimeo/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/vimeo/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/vimeo/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/vimeo/skills/typescript/typescript-error-handling/SKILL.md b/plugins/vimeo/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/vimeo/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/vimeo/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError

extends Error { - readonly status: number; // the HTTP status - readonly headers: Headers; // the response headers - readonly payload: ErrorPayload

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/vimeo/skills/typescript/typescript-getting-started/SKILL.md b/plugins/vimeo/skills/typescript/typescript-getting-started/SKILL.md index c8c080a77..c0e0cf208 100644 --- a/plugins/vimeo/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/vimeo/skills/typescript/typescript-getting-started/SKILL.md @@ -5,7 +5,7 @@ description: "Vimeo API TypeScript SDK identity and lookup layer (TypeScript/Jav # Getting started with the Vimeo API TypeScript SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Vimeo API TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-vimeo` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Vimeo API TypeScript SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source files it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `typescript-integrate-vimeo-api` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated TypeScript SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `typescript-client-initialization`, `typescript-authentication`, `typescript-calling-endpoints`, `typescript-models`, `typescript-error-handling`, `typescript-configuration-resilience` and `typescript-testing`. @@ -20,39 +20,41 @@ Verified against `package.json` and `sdk-map.md` of the generated package at ver | API | Vimeo API | | Package name (what you install, and what you import) | `vimeo-api` — **not on npm**; built from source (see *Install*) | | Import specifier | `vimeo-api` — the package root is the **only** entry; deep imports do not resolve | +| Source repository | https://github.com/context-plugins/vimeo-typescript-sdk (branch `main` — the ref this map documents) | | Version | `3.4.9` (API spec version `3.4.9`) | | Client class | `VimeoApiClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new VimeoApiClient(clientOptions: Partial = {})` — **every** field is optional, so `new VimeoApiClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `bearer` · `oauth2AuthorizationCode` · `oauth2AuthorizationCodeStrategy` · `oauth2ClientCredentials` · `oauth2ClientCredentialsStrategy`. `timeout` defaults to `60_000` ms | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new VimeoApiClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new VimeoApiClient()` compiles. Fields: `serverOptions` · `retry` · `fetch` · `bearer` · `oauth2AuthorizationCode` · `oauth2AuthorizationCodeStrategy` · `oauth2ClientCredentials` · `oauth2ClientCredentialsStrategy`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **Bearer token** — set `ClientOptions.bearer`
**OAuth 2 authorization code** — set `ClientOptions.oauth2AuthorizationCode`
**OAuth 2 client credentials** — set `ClientOptions.oauth2ClientCredentials` | -| Environments | 1 environment (`ServerEnvironment.Production` *(default)*) × 1 server group | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`), defaulting to `https://api.vimeo.com` | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Environments | no selector — one environment, so none is emitted; 1 server group | +| Base-URL config | `serverOptions.baseUrl` (`src/servers.ts`), defaulting to `https://api.vimeo.com` | +| Log environment variable | `VIMEO_API_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | -| Surface | 520 operations · 100 resources · 784 models · 270 open enums · 1 union · 415 per-operation error subclasses | +| Surface | 520 operations · 100 resources · 784 models · 281 open enums · 1 union · 415 per-operation error subclasses | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, import specifier, the auth *pattern*, the base-URL knob), while the actual integration code comes from the companion skills. Load each one as you reach its step (see **Integration workflow** below) and confirm its types against the installed package. ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/vimeo-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 1055 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, 1066 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { VimeoApiClient, ServerEnvironment, ResponseError, VimeoApiError } from "vimeo-api"; +import { VimeoApiClient, ApiError, VimeoApiError } from "vimeo-api"; import type { ClientOptions, ApiApp } from "vimeo-api"; ``` @@ -66,21 +68,19 @@ Things the specifier alone will not tell you: Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, every model type) must be imported with `import type`. Under `exactOptionalPropertyTypes`, **omit or spread** an absent optional field rather than assigning `undefined` to it. -## Environments +## Hosts -`ClientOptions.serverEnvironment` selects one environment for the whole client (`src/servers.ts`). `ServerEnvironment` is a `const` object with a derived union type — not a TypeScript `enum` — and unlike the model enums it is **closed**, so only its declared members are assignable. +This API declares one environment, so the SDK has **no environment selector** — there is no `ServerEnvironment` type and no field to set (`src/servers.ts`). The host below is the only one the SDK knows, and the override in the last column is the only way to reach another. -| Group | Environment | Base URL | Override at | -| --- | --- | --- | --- | -| `default` | `production` *(default)* | `https://api.vimeo.com` | `serverOptions.default.production.baseUrl` | +| Group | Base URL | Override at | +| --- | --- | --- | +| `default` | `https://api.vimeo.com` | `serverOptions.baseUrl` | Consequences to state on every contract sheet that touches configuration: -- Constructing the client with no options selects **`ServerEnvironment.Production`**, silently. -- ⚠ **This SDK declares exactly one environment**, so reaching any other host is a `serverOptions` override, not an environment value — `serverOptions: { default: { production: { baseUrl: "…" } } }`. Note the key is still `production`: the environment name and the host it points at are now decoupled, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the deployment's intent explicit where the client is built, and verify it. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- ⚠ **This SDK declares exactly one environment**, so it emits no `ServerEnvironment` at all and reaching any other host is a `serverOptions` base-URL override — `serverOptions: { baseUrl: "…" }`. Nothing in the options then records **which** deployment this is, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the intent explicit where the client is built, and verify it. +- An override merges with the built-in default **key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. ## Auth pattern (3 schemes) @@ -106,9 +106,9 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. -**OAuth 2 fetches and caches its own token.** The token request goes through **the same client** — same `timeout`, same `fetch` — sends a form-urlencoded body, and decodes the response against a schema rather than casting it. A token is cached until shortly before it expires; a response with no `expires_in` is treated as never expiring (RFC 6749 §5.1); concurrent callers share one in-flight fetch. **A refused token endpoint rejects with `AuthError`**, wrapping the underlying `ResponseError` as `cause` — so a bad secret never looks like the business call failing, and `AuthError` is *not* a `ResponseError`. +**OAuth 2 fetches and caches its own token.** The token request goes through **the same client** — same `fetch`, same `retry` policy — sends a form-urlencoded body, and decodes the response against a schema rather than casting it. A token is cached until shortly before it expires; a response with no `expires_in` is treated as never expiring (RFC 6749 §5.1); concurrent callers share one in-flight fetch. **A refused token endpoint rejects with `AuthError`**, wrapping the underlying `ApiError` as `cause` — so a bad secret never looks like the business call failing, and `AuthError` is *not* an `ApiError`. The token request is itself a `POST` with its own timeout, retried only when `httpMethodsToRetry` names `POST`. | Flow | Token endpoint | Client credentials travel | | --- | --- | --- | @@ -228,7 +228,7 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c | `client.videosVideoComments` | `VideosVideoComments` | 9 | `createComment` · `createCommentAlt1` · `createCommentReply` · `deleteComment` · `editComment` · `getComment` · `getCommentReplies` · `getComments` · `getCommentsAlt1` | | `client.watchLaterQueueEssentials` | `WatchLaterQueueEssentials` | 8 | `addVideoToWatchLater` · `addVideoToWatchLaterAlt1` · `checkWatchLaterQueue` · `checkWatchLaterQueueAlt1` · `deleteVideoFromWatchLater` · `deleteVideoFromWatchLaterAlt1` · `getWatchLaterQueue` · `getWatchLaterQueueAlt1` | -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** A name that collides inside its namespace is promoted to `RequestParams` instead — no operation in this SDK takes that spelling at this version. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -236,7 +236,7 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: @@ -245,11 +245,11 @@ Locate the installed package before you rely on a lookup: node -e "console.log(require.resolve('vimeo-api/package.json'))" ``` -Failing that it is at `node_modules/vimeo-api/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/vimeo-api/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/vimeo-api/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -257,7 +257,7 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/vimeo-api/`: @@ -267,13 +267,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -287,30 +289,38 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `VIMEO_API_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new VimeoApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters and the OAuth 2 token cache live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) -2. **Authentication** — load **typescript-authentication** before you set credentials. The 3 schemes are `bearer`, `oauth2AuthorizationCode`, `oauth2ClientCredentials` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; the token is fetched lazily and cached on the client; a **failed token fetch** raises `AuthError`, which is not a `ResponseError` and **bypasses `.asApiResult()` entirely**; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new VimeoApiClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters and the OAuth 2 token cache live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) +2. **Authentication** — load **typescript-authentication** before you set credentials. The 3 schemes are `bearer`, `oauth2AuthorizationCode`, `oauth2ClientCredentials` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; the token is fetched lazily and cached on the client; a **failed token fetch** raises `AuthError`, which is not an `ApiError` and **bypasses `.asApiResult()` entirely**; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **174 operations resolve to `undefined`**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `VimeoApiError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `VimeoApiError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `VIMEO_API_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK Beyond the usual signatures and model members, a contract sheet for the Vimeo API TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. -1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment, `ServerEnvironment.Production`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. +1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment and emits no `ServerEnvironment`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. 2. **174 operations resolve to `undefined`** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 415 of 520 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. **That `Error`, `Event` imported from this package are model types, not the globals of those names** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `VimeoApiError` for the same reason. -10. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 415 of 520 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. **That `Error`, `Event` imported from this package are model types, not the globals of those names** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `VimeoApiError` for the same reason. +12. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/vimeo/skills/typescript/typescript-integrate-vimeo/SKILL.md b/plugins/vimeo/skills/typescript/typescript-integrate-vimeo-api/SKILL.md similarity index 51% rename from plugins/vimeo/skills/typescript/typescript-integrate-vimeo/SKILL.md rename to plugins/vimeo/skills/typescript/typescript-integrate-vimeo-api/SKILL.md index eb5c30f52..7d82fb255 100644 --- a/plugins/vimeo/skills/typescript/typescript-integrate-vimeo/SKILL.md +++ b/plugins/vimeo/skills/typescript/typescript-integrate-vimeo-api/SKILL.md @@ -1,6 +1,6 @@ --- -name: "typescript-integrate-vimeo" -description: "MANDATORY FIRST STEP for Vimeo API TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Vimeo API in TypeScript/Node — add webhook, delete webhook, get webhook, get webhooks, update webhook, get endpoints, or when the Vimeo API TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `vimeo-api` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (vimeo-api-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares only ServerEnvironment.Production, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." +name: "typescript-integrate-vimeo-api" +description: "MANDATORY FIRST STEP for Vimeo API TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Vimeo API in TypeScript/Node — add webhook, delete webhook, get webhook, get webhooks, update webhook, get endpoints, or when the Vimeo API TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `vimeo-api` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (vimeo-api-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares one environment and no ServerEnvironment, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." --- # Vimeo API TypeScript SDK — Integration workflow (lookup layer + contract sheet) @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/vimeo-api/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('vimeo-api/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('vimeo-api/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. @@ -23,14 +23,14 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/vimeo-api-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/vimeo-api-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, **module format — ESM or CJS, which changes how the SDK is imported and whether `instanceof` on its error classes is trustworthy**, and the runtime: Node, a bundled browser app, or an edge runtime) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - **establish the toolchain before you need it**: the package manager, whether `vimeo-api` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; - a baseline run of the project's checks (`tsc --noEmit`, the test runner, the linter — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment (`ServerEnvironment.Production`), so **any other host is a `serverOptions` base-URL override rather than an environment value**; +- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment, so it emits no `ServerEnvironment` at all and **any other host is a `serverOptions` base-URL override rather than an environment value**; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your credential may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; - setting up your task tracking. @@ -40,11 +40,85 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** installing `vimeo-api` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. @@ -58,13 +132,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp ### Step 2 — Implement from the contract sheet 1. Read `vimeo-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment — `ServerEnvironment.Production` — so a different host is a `serverOptions` override under a key still named after `production`: the environment name and the host it points at are decoupled, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. +2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment and emits no selector for it, so a different host is a `serverOptions` base-URL override. Nothing in the options then records **which** deployment this is, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. 3. **Build the client once and hold it.** Resources are memoized lazy getters on it and the OAuth 2 token cache lives on it, so rebuilding one per request throws that state away. There is no `close()` — it owns a `fetch`, not a pool. 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from the **174 operations that resolve to `undefined`** (every operation whose success declares no body — the map's **Returns** bullet names them). `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `VimeoApiError` family, told apart by `err.kind`. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `VIMEO_API_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions @@ -74,7 +150,7 @@ A standalone Vimeo API question with no code change: look it up in `typescript-g - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. - **Never write any Vimeo API or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. - **Don't web-search Vimeo API topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Vimeo API docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. diff --git a/plugins/vimeo/skills/typescript/typescript-models/SKILL.md b/plugins/vimeo/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/vimeo/skills/typescript/typescript-models/SKILL.md +++ b/plugins/vimeo/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/vimeo/skills/typescript/typescript-models/reference.md b/plugins/vimeo/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/vimeo/skills/typescript/typescript-models/reference.md +++ b/plugins/vimeo/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/vimeo/skills/typescript/typescript-testing/SKILL.md b/plugins/vimeo/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/vimeo/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/vimeo/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect.

; // the decoded error body, as a discriminated union - // message is `${status} ${statusText}` — e.g. "404 Not Found" +class ApiError { + readonly kind: "api"; + readonly status: number; // the HTTP status + readonly headers: Headers; // the response headers + readonly payload: /* the decoded error body, as a discriminated union */; + readonly method: HttpMethod; // the call it names + readonly uri: string; } ``` `status` and `headers` are always there, for any error status, declared or not — you never have to -choose between a typed body and knowing the status. - -⚠ **`err.message` deliberately omits the body.** It is only `${status} ${statusText}` — -`"422 Unprocessable Entity"`. That keeps response bodies out of logs and stack traces by default, -which is a sound default and a trap: a handler that logs `err.message` and nothing else records that -something failed and discards every diagnostic. Read `err.payload` explicitly and log the fields you -choose. - -`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with -`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is -always `undefined`, and spreading it gives you nothing. +choose between a typed body and knowing the status. Anything outside `200`–`299` is an error status, +so a `3xx` lands here too. ### Which error does an operation reject with? @@ -101,8 +162,8 @@ Four places give you the answer, in order of preference: 3. **`api-reference.md`** — per-operation prose, when you need what an arm *means* rather than its shape. 4. **`src/resources/{resource}.ts`** — the source. The subclass's `static readonly errors` table is - the matcher list itself: one row per arm, each spelling `on` (the status or `[from, to]` range), - `kind`, and the decoder. + the matcher list itself: one row per arm, each spelling `on` (the status, a `[from, to]` range, or + `"default"`), `kind`, and the decoder. All of these ship inside `node_modules/{package-name}/`, so read them there rather than the compiled `dist/`. @@ -110,8 +171,8 @@ All of these ship inside `node_modules/{package-name}/`, so read them there rath - **Case A — typed arms.** The spec declared failure bodies, so the generator emitted a subclass named `{Operation}Error`, exported from the **enclosing class's merged namespace** — you catch `{Resource}.{Operation}Error`, or `{Api}Client.{Operation}Error` for an operation the spec left - untagged. Narrow on `err.payload.kind`. -- **Case B — no typed arms.** `E` is the **base `ResponseError`**, its payload is always the + untagged. It redeclares `payload` with the operation's own arms. Narrow on `err.payload.kind`. +- **Case B — no typed arms.** `E` is the **base `ApiError`**, its payload is always the `"undeclared"` arm, and there is no subclass to catch. Guessing wrong is *sometimes* a compile error and sometimes not, and the direction that looks safe is @@ -123,21 +184,22 @@ all. Take the case from the map every time; the compiler is not a check on this. ### Case A — reading the typed payload > **This section applies only where the operation declares typed arms.** An operation whose **Error** -> bullet names the base `ResponseError` has nothing to narrow — its payload is always `"undeclared"`. +> bullet names the base `ApiError` has nothing to narrow — its payload is always `"undeclared"`. > Skip to **Case B**. `err.payload` is a discriminated union with **one arm per failure response the spec declared**, plus `"undeclared"`. Two things about arm names catch people out: - **The name comes from the body's schema, not from the status.** An arm whose body is a direct model - reference is named after that model in lower camel (`apiError`); anything else — a primitive, an - array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`). So two statuses that - return the *same* model give you two arms whose names differ only by a numeric suffix (`apiError`, - `apiError2`). Read the arm names off the map; do not derive them from the status. + reference is named after that model in lower camel (`validationError`); anything else — a + primitive, an array, a map, or no content — is named `error{Status}` (`error400`, `error4XX`, + `errorDefault`). So two statuses that return the *same* model give you two arms whose names differ + only by a numeric suffix (`validationError`, `validationError2`). Read the arm names off the map; + do not derive them from the status. - **One arm can still cover many statuses** — not because two statuses were merged, but because the - spec declared a *range*. A `4XX` arm covers `[400, 499]`, and a `default:` arm covers **`[400, 599]` - only**, so a `304` falls through a `default:` arm into `"undeclared"`. Check `err.status` when you - need the specific one. + spec declared a *range* or a `default:`. A `4XX` arm covers `[400, 499]`; a `default:` arm covers + **every error status no other arm covers**, `3xx` included. Check `err.status` when you need the + specific one. ```ts import { {Resource} } from "{package-name}"; @@ -156,20 +218,24 @@ try { console.error(err.status, new TextDecoder().decode(err.payload.rawBody)); break; } + } else { + throw err; // every other failure, for the rest of your ladder } } ``` **`"undeclared"` is always an arm, and it is not a catch-all.** It carries -`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for statuses no declared arm covers. -A status that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that -handles only `"undeclared"` silently drops every typed body. Handle every arm the map lists, and put -`"undeclared"` last. There is no operation whose failure is guaranteed typed: the generator emits the -`"undeclared"` arm unconditionally, so any operation can hand you raw bytes for a status it does not -document. +`{ kind: "undeclared"; rawBody: ArrayBuffer }` and fires **only** for a status no declared arm covers +— or, where the operation declares a `default:` arm, for a body that arm could not decode. A status +that has an arm lands in that arm and leaves `"undeclared"` unreached — so a `catch` that handles only +`"undeclared"` silently drops every typed body. Handle every arm the map lists, and put `"undeclared"` +last. There is no operation whose failure is guaranteed typed: the generator emits the `"undeclared"` +arm unconditionally, so any operation can hand you raw bytes for a status it does not document. An arm can declare **no body** (`body: undefined`) — the spec named the status but gave it no schema. -The arm still exists and still tells you which status class you are in. +It accepts only an **empty** body: any body on a status it covers (a JSON error message is typical) +rejects with `DecodeError`, even through `.asApiResult()`, and `err.status` still names it (see +**`"decode"`**). On a `"default"` arm that body degrades to `"undeclared"` instead. **The arms are per operation, and are not one set across the API.** A description that declares a different error schema per tag gets a different model — and therefore a differently-named arm — per @@ -192,19 +258,25 @@ switch (err.payload.kind) { } ``` -### Case B — the base `ResponseError` +### Case B — the base `ApiError` -Nothing to narrow. Read the status and the raw bytes straight off it: +Nothing to narrow — and the obvious test narrows **nothing**. On the base `ApiError` the payload's +`kind` is typed `string`, so `err.payload.kind === "undeclared"` leaves `payload` unnarrowed and +`rawBody` unreachable. Test for the member instead: ```ts +import { ApiError } from "{package-name}"; + try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { + if (err instanceof ApiError) { console.error(err.status, err.headers.get("x-request-id")); - if (err.payload.kind === "undeclared") { + if ("rawBody" in err.payload) { console.error(new TextDecoder().decode(err.payload.rawBody)); } + } else { + throw err; } } ``` @@ -214,15 +286,19 @@ with HTML or plain text. Decode it to a string before you try to parse it, and e ### Matcher precedence -For a subclass with several arms, an **exact numeric status is matched across the whole table first**; -only then does the first covering range win. So a specific `404` arm beats a `4XX` arm regardless of -declaration order. The generator also emits the rows narrowest-first, so a `default:` arm cannot shadow -a `5XX` one. +For a subclass with several arms, the runtime matches in **three passes**: an exact numeric status +across the whole table first, then the first covering range, and last a `"default"` arm where the +spec declared one. So a specific `404` arm beats a `4XX` arm regardless of declaration order, and a +`default:` arm can never shadow a narrower one. + +A body that does not fit the arm it matched is a **`DecodeError`** — except on `"default"`, which +describes no status in particular and so **degrades to the `"undeclared"` arm**, raw bytes and all, +rather than throwing. ### Catch order -Narrow from most specific to least. A generated subclass extends `ResponseError`, so the subclass arm -must come first or it is unreachable: +`instanceof` narrows from most specific to least, and every generated subclass extends `ApiError`, +which is itself in the family — so the subclass test must come first or it is unreachable: ```ts try { @@ -230,71 +306,76 @@ try { } catch (err) { if (err instanceof {Resource}.{Operation}Error) { // typed arms available on err.payload.kind - } else if (err instanceof ResponseError) { - // any other error status — payload is "undeclared" + } else if (err instanceof ApiError) { + // any other operation's error status — payload is "undeclared" } else if (err instanceof {Api}Error) { - // transport / SDK-side failure + // every other kind: decode, encode, connection, timeout, auth } else { throw err; } } ``` -## Family B — the `{Api}Error` set +## `"decode"` — the answer did not fit + +A `DecodeError` means the API **did** answer — it carries `status` and `headers` — but the body could +not be turned into the declared value. `cause` says how: a `SchemaError` when the body did not fit its +contract, a `SyntaxError` when it was not JSON, or the platform's read fault when the body died +mid-stream after the response line. A body arriving where the operation declares none has no `cause`. -One class per `kind`, all extending the abstract `{Api}Error`. Narrow on `err.kind` — it is a literal -union, so a `switch` over it is exhaustive: +Which status it came on matters a lot, and `err.status` tells you: -| `err.kind` | Class | Means | -| --- | --- | --- | -| `"connection"` | `ConnectionError` | the `fetch` call rejected, or the body read failed mid-stream | -| `"timeout"` | `TimeoutError` | the client-level `timeout` elapsed | -| `"abort"` | `AbortError` | the per-call signal aborted (including one already aborted) | -| `"sdk"` | `SdkError` | a defect on the SDK side — an unfilled path placeholder, no reachable `fetch`, a response the decoder could not handle | -| `"schema"` | `SchemaError` | a value failed its schema in **either** direction | -| `"auth"` | `AuthError` | a credential could not be **obtained** | +- **On a success status** — the API answered `2xx` and the SDK could not decode the body. The outcome + is genuinely **unknown**: the call may have taken effect. +- **On a declared error arm** — the API rejected the request and only the *detail* was lost. The + status survives on the `DecodeError`, so you still know it was, say, a `422`. -Every one carries `message` and, where there was an underlying failure, `cause`. +Mapping both onto one 5xx is wrong half the time — it tells a retrying caller to keep retrying +something that can never succeed. Only a **declared** arm can decode-fail: the `"undeclared"` arm reads +raw bytes and never decodes, and a `default:` arm degrades instead of throwing. ```ts -if (err instanceof {Api}Error) { - switch (err.kind) { - case "timeout": - case "connection": - // retryable-shaped: nothing was learned about the request's fate - break; - case "abort": - // the caller cancelled — usually not an error to report - break; - case "schema": - // a contract mismatch, not a transient failure - break; - case "auth": - case "sdk": - // a configuration or code defect - break; - } +if (err instanceof DecodeError) { + console.error(err.status, err.message); // the call, and that the body did not decode + console.error(err.cause); // the SchemaError naming the failing field, or the parse fault } ``` -### `AuthError` is never a 401 +## `"encode"` — nothing was sent -**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is a -Family A `ResponseError` like any other status. The two are disjoint and one arm cannot absorb the -other. +An `EncodeError` means a request field failed encoding, so **no request reached the network**. This is +a bug in your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not +let a production ladder quietly absorb it — it should fail loudly in development. Its `cause` is the +`SchemaError` that rejected the value, and that error's `message` names the field. -A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, -so the **next** call re-acquires. The current request is **not** retried — it fails, and recovery -happens on the following attempt. If you want the current call to succeed after a token refresh, retry -it yourself. +## `"auth"` — a credential could not be obtained -`AuthError` itself means the token grant failed, or PKCE was disabled without a client secret. When the -grant failed, **`.cause` is the token endpoint's own `ResponseError`** — that is where the token -endpoint's status and body are, and the only place a "bad client credentials" diagnosis can be read. -Never log an `AuthError` without its `cause`. +**`AuthError` is about obtaining a credential, not about being refused one.** A 401 *from the API* is +an `ApiError` like any other status, so one `catch` arm cannot absorb the other. -Auth resolves *before* the request is built, so an `AuthError` means **nothing was sent** — it is a -configuration fault, not a rejection. Check it early in your ladder. +A 401 does have one auth consequence: it **invalidates whatever that operation's scheme had cached**, +so the **next** call re-acquires. `401` is not in the default `statusCodesToRetry`, so the current +request fails and recovery happens on the following call. To have the current call re-sent with a +fresh token instead, add `401` to `statusCodesToRetry`: every attempt resolves its credential again, so +the retry carries the re-acquired token. A static key is re-sent unchanged, so that only helps a scheme +that fetches its credential. + +`AuthError` means the token grant failed, every configured branch of an alternatives requirement +failed, PKCE was disabled without a client secret, or your own authorization-code prompt rejected. Its +`method` and `uri` name **the operation you called**, not the token endpoint. When the grant failed, +**`.cause` is the token endpoint's own `ApiError`** — that is where the token endpoint's status and +body are, and the only place a "bad client credentials" diagnosis can be read. Never log an `AuthError` +without its `cause`. + +An **alternatives** requirement falls through: a configured scheme that throws is not the end of it, +the next configured one is tried, and only when all of them have failed does it throw. When exactly +one was configured, its failure surfaces as it would from that scheme alone — a refused token endpoint +is still an `AuthError` with the endpoint's `ApiError` on `.cause`, never the bare `ApiError`. +Otherwise it is an `AuthError` whose `cause` is an `AggregateError` holding what each branch threw, in +the order tried. An abort or a timeout escapes immediately rather than being collected. + +Auth resolves *before* the request is sent, so an `AuthError` means **nothing was sent** — it is an +auth fault, not a rejection. Check it early in your ladder. ⚠ **The token is acquired lazily, on first use — so an auth misconfiguration surfaces from an operation call, not from client construction.** The rejection points at whichever operation happened to @@ -302,50 +383,18 @@ run first, which reads like a problem with that operation rather than with the c up. It also rejects in `.asApiResult()` mode, for the same reason: the grant happens before a request exists to return a result for. -### `SchemaError` — three different facts wearing one type - -`SchemaError` fires in both directions, and which one it was matters a lot: - -- **Outbound** — a request field failed encoding. **No request reached the network.** This is a bug in - your code (a wrong type, a bad date format), not an API failure. Never retry it, and do not let a - production ladder quietly absorb it — it is the same class of thing as an unfilled path placeholder - (`SdkError`) or a mistyped request object, and it should fail loudly in development. -- **Inbound, on a success status** — the API answered `2xx` and the SDK could not decode the body: - malformed JSON, a schema mismatch, or a non-empty body on an operation declared to return none. The - outcome is genuinely **unknown**. -- **Inbound, on a declared error arm** — and this one is a trap. The arm's body is decoded **while the - `ResponseError` is still being constructed**, so a `SchemaError` there **replaces** the - `ResponseError` entirely. Your Family A `catch` never fires, and `err.status` is gone with it. - Identical type, opposite meaning: the success case is "outcome unknown", this case is "you were - rejected and I lost the reason". Mapping both onto one 5xx is wrong half the time — it tells a - retrying caller to keep retrying something that can never succeed. - -Only a **declared** arm can do this. The `"undeclared"` arm reads raw bytes and never decodes, so a -Case B operation cannot lose its status this way. - -```ts -if (err instanceof SchemaError) { - console.error(err.message); // names the failing path: "amount: expected number, received string" - console.error(err.rawBody); // the value that failed — the parsed body, or your input -} -``` - -The `message` already names the path and the expectation, so log it before reaching for `rawBody`. If -you need the status on the error-arm case, capture it in a wrapping `fetch` — the SDK has already -discarded it by the time your `catch` runs. - -### A missing field is not a `SchemaError` — it is `undefined` +## A missing field is not an error — it is `undefined` This is the gap the error types leave, and the one most likely to reach production. A model member the description did not mark **required** is emitted as `member?: T` over an optional schema entry, so a -response that simply **omits** it validates cleanly. No `SchemaError` is thrown, and nothing warns you: +response that simply **omits** it validates cleanly. No `DecodeError` is thrown, and nothing warns you: ```ts const value = await client.{resource}.{operation}({ /* ... */ }); // 200, truncated body value.{member}; // undefined — not an error, and not a null you declared ``` -A `SchemaError` on a success status means a **type** mismatch or an unparseable body, not an absent +A `DecodeError` on a success status means a **type** mismatch or an unparseable body, not an absent field. So the guard belongs on the value, not in the `catch`: ```ts @@ -365,8 +414,8 @@ error arm**: an arm whose fields are all optional will accept almost any JSON ob ## The non-throwing form -`.asApiResult()` turns the **HTTP error status** branch into a value. It does **not** make the call -non-throwing: Family B still rejects. +`.asApiResult()` turns the **HTTP error status** into a value. It does **not** make the call +non-throwing: every other failure still rejects. ```ts try { @@ -375,10 +424,10 @@ try { if (outcome.ok) { use(outcome.value); } else { - // outcome.status, outcome.headers, outcome.errorMessage, outcome.error (the payload) + // outcome.status, outcome.headers, outcome.message, outcome.method, outcome.uri, outcome.payload } } catch (err) { - // still needed — Family B lands here + // still needed — decode, encode, connection, timeout, auth and a caller abort land here } ``` @@ -387,74 +436,97 @@ no per-status judgement. Reach for it when an error status is an expected outcom found yet", a `409` meaning "already exists") and you would otherwise be using exceptions for control flow, or when you need `status` and `headers` on the *success* path. -Two mechanics to know: `outcome.error` is the **payload**, not the `ResponseError` — so you narrow on -`outcome.error.kind`, exactly as you would on `err.payload.kind`; and `.asApiResult()` must be called -on the value the operation returned, **before any `.then()`**, because `ApiPromise` overrides +Two mechanics to know: the failure branch is a plain object carrying **the error's own members under +their own names** — `status`, `headers`, `message`, `method`, `uri` and `payload` — never the error +instance, so you narrow on `outcome.payload.kind` exactly as you would on `err.payload.kind`, and a +handler moves between the two forms by swapping the prefix; and `.asApiResult()` must be called on the +value the operation returned, **before any `.then()`**, because `ApiPromise` overrides `Symbol.species` and the method is gone from what `.then()` hands back (see **typescript-calling-endpoints**). ⚠ **This mode is not rejection-free, and the cases it does not convert are the ones people assume it -does.** A decode failure is not an API error, so it propagates in both modes rather than becoming an -`ok: false` value — a `SchemaError` on a `2xx` body, and a `SchemaError` on a **declared error arm**, -both still reject. So does a failed token acquisition, because the credential is resolved while the -request is being built, before anything is sent. `try`/`catch` is still required around +does.** A decode failure is not an API error status, so it propagates in both modes rather than +becoming an `ok: false` value — a `DecodeError` on a `2xx` body, and a `DecodeError` on a **declared +error arm**, both still reject. So does a failed token acquisition, because the credential is resolved +before anything is sent, and so does a caller abort. `try`/`catch` is still required around `.asApiResult()`; it narrows what you catch, it does not remove the need to catch. ## Transport failures, and guarding every call site -Family A covers only the case where the API answered. It does **not** cover a host that is unreachable, -DNS that failed, a connection that dropped, or the client-level timeout elapsing — those are Family B, -and a `catch` whose only arm is `err instanceof ResponseError` lets them escape and take down whatever -was running the call. +`"api"` covers only the case where the API answered. It does **not** cover a host that is unreachable, +DNS that failed, a connection that dropped, or an attempt's timeout elapsing — those are +`"connection"` and `"timeout"`, and a `catch` whose only arm is `err instanceof ApiError` lets them +escape and take down whatever was running the call. + +**Every one of them reaches you after the retries are spent.** On a `GET`, `HEAD`, `PUT` or `OPTIONS` +call the default policy re-sends a `408`, `429`, `500`, `502`, `503` or `504`, a connection failure +and a timeout up to +three times before the call rejects, so what you catch is the **last** attempt's failure — an +`ApiError` for the final status, or a `ConnectionError` or `TimeoutError` — in exactly the shape it +would have had with retrying off. A write outside `httpMethodsToRetry` rejects on its first failure. +Do not retry a family error the SDK already retried; see **typescript-configuration-resilience**. **Unlike SDKs that let their HTTP library's exceptions through, this one wraps them.** Whatever your `fetch` rejects with becomes a `ConnectionError`, with the original on `.cause` — so your boundary never imports the transport's exception types, and it does not need revisiting when you swap `fetch` -for a wrapper. Two things pass through unwrapped: anything that is already an SDK error (a `CoreError` -subclass your own wrapper threw), and an abort, which surfaces as `AbortError` or `TimeoutError` -according to which signal fired. If you wrap `fetch` to add retries or logging -(**typescript-configuration-resilience**), throw an SDK error type from it or accept that yours will -arrive as `err.cause`. - -**Convert both families to your own error type in one place.** If you wrap the SDK behind your own +for a wrapper. Two things pass through unwrapped: anything that is already in the family (a +`{Api}Error` your own wrapper threw), and a cancellation — the caller's own abort `reason`, or the +SDK's `TimeoutError` when an attempt's timeout fired, whatever your `fetch` rejected with. If you wrap +`fetch` to add a guard (**typescript-configuration-resilience**), throw a family error from it or +accept that yours will arrive as `err.cause` — and know that a plain error thrown there is a +`ConnectionError` the retry loop retries. + +**Convert the family to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), do the conversion at that boundary so the -rest of the code has one failure type to handle instead of three: +rest of the code has one failure type to handle: ```ts +class MyUpstreamError extends Error { status: number; reason: string | undefined; constructor(status: number, reason: string | undefined, opts?: ErrorOptions) { super(reason, opts); this.status = status; this.reason = reason; } } + function translate(err: unknown): never { // Most specific first — the typed subclass, then any other error status. if (err instanceof {Resource}.{Operation}Error && err.payload.kind === "{arm}") { - throw new MyValidationError(err.payload.body, { cause: err }); - } - if (err instanceof ResponseError) { - if (err.status === 404) throw new MyNotFoundError({ cause: err }); - if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); - throw new MyUpstreamError(err.status, { cause: err }); + throw new MyUpstreamError(err.status, err.payload.body?.{reasonField}, { cause: err }); // one branch per arm the map lists; join a list of reasons } if (err instanceof {Api}Error) { - if (err.kind === "timeout" || err.kind === "connection") { - // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — - // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. - throw new MyTransientError({ cause: err }); + switch (err.kind) { + case "api": { + if (err.status === 429) throw new MyRateLimitedError(err.headers.get("retry-after"), { cause: err }); + // Every arm without its own branch lands here — raw bytes, or a declared arm's body. `reasonIn` is YOUR + // helper: parse, take the reason field, join a list, cap the length; nothing usable (an HTML page) → undefined. + const reason = reasonIn("rawBody" in err.payload ? err.payload.rawBody : (err.payload as { body?: unknown }).body); + throw new MyUpstreamError(err.status, reason, { cause: err }); + } + case "timeout": + case "connection": + // Nothing was learned about the request's fate. For a write, that is "unknown", not "failed" — + // the bytes may have reached the API before the failure. Tell the caller that, don't say it failed. + throw new MyTransientError({ cause: err }); + case "decode": + // The API answered and the body was unreadable — err.status says whether it was a success. + throw new MyProviderUnreadable(err.status, { cause: err }); + case "encode": + case "auth": + throw new MyIntegrationDefect(err.message, { cause: err }); } - throw new MyIntegrationDefect(err.message, { cause: err }); } - throw err; + throw err; // an abort's reason, a configuration fault, or a bug } ``` Always pass `{ cause: err }` — the SDK's own errors chain their underlying failure that way (an -`AuthError` hides the token endpoint's `ResponseError` there, a `SchemaError` the validator's report), -and dropping the link is what makes production diagnosis hard. +`AuthError` hides the token endpoint's `ApiError` there, a `DecodeError` or `EncodeError` the +`SchemaError` naming the field), and dropping the link is what makes production diagnosis hard. **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that create or modify something and overlook the calls that only read — especially reads on a routine path (loading a screen, a scheduled job, a startup or health check). A connection failure during a read fails just as hard as one during a write. A call left unguarded next to one that is guarded is the one that breaks. -⚠ **A rejection you never await is silently swallowed.** `ApiPromise` attaches its own no-op `.catch()` -at construction, so a fire-and-forget call produces **no `unhandledRejection`** — the failure -disappears without a trace. Await every call, or attach your own handler. +⚠ **A rejection you never await is an unhandled rejection**, exactly like any other promise's: +`unhandledRejection` on Node — which by default ends the process — and `unhandledrejection` in a +browser. Await every call, or attach your own handler. `.asApiResult()` marks the promise handled as +its first act, so call it on the value the operation returned, in the same turn. ## Presenting failures at your boundary — coherent, distinct, leak-free @@ -466,12 +538,12 @@ on the wire. Three rules, applied at the one boundary where SDK failures become identically at every call site — same order, same conversion. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct.** Family A always hands you `status`, so that is your discriminator -— and where the operation declares arms, `payload.kind` says more than the status does, because it -names the *body* the API sent rather than the class of failure. Carry whichever you keyed on into your -own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing everything -into one blanket status (502 for all of it) throws away the only signal that separates "you sent -something invalid" from "the provider is down". +**Keep distinct failures distinct.** An `ApiError` always hands you `status`, so that is your +discriminator — and where the operation declares arms, `payload.kind` says more than the status does, +because it names the *body* the API sent rather than the class of failure. Carry whichever you keyed on +into your own error type; a status dropped at this boundary cannot be recovered downstream. Collapsing +everything into one blanket status (502 for all of it) throws away the only signal that separates "you +sent something invalid" from "the provider is down". One ladder, in the single place where your error type becomes a caller-facing status. This is where the discriminator you carried gets read back — a ladder with **no branch reading it** is incomplete: @@ -484,9 +556,9 @@ function toHttp(err: unknown): { status: number; message: string } { if (err instanceof MyUpstreamError) { // OUR credentials — likewise not the caller's to fix. if (err.status === 401 || err.status === 403) return { status: 502, message: "Provider unavailable." }; - // The provider rejected THE CALLER'S request — hand back the same status so they can act on it. - if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.message }; - return { status: 502, message: err.message }; + // The provider rejected THE CALLER'S request — hand back the same status and its reason. + if (err.status >= 400 && err.status < 500) return { status: err.status, message: err.reason ?? "Request rejected." }; + return { status: 502, message: "Provider error." }; } // Transport, timeout, provider 5xx — no meaningful caller status. @@ -501,11 +573,10 @@ unauthenticated or throttled when they are neither. Those belong in the 5xx buck conflict and not-found are the caller's to fix. And keep the default arm at 5xx: a status you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**An unreadable body is not one case but two — decide which before you map it.** An unreadable -**success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected +**An unreadable body is not one case but two — decide which before you map it.** A `DecodeError` on a +**success** status is genuinely unknown: 5xx. One on an **error** status is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to keep retrying -something that can never succeed. The `SchemaError` section above is where that distinction comes from, -and neither case can be read off the error's type. +something that can never succeed. `err.status` is what separates them; the type alone does not. **Never map a decode failure onto a domain absence.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -514,9 +585,10 @@ turns a corrupt response into a spurious create; more generally it produces a co which is worse than an error. If the operation's miss really is signalled by an empty body, match on *empty*, not on *unparseable*. -**Never put an SDK `message` on the wire.** A `SchemaError`'s message names a field path and the value -that failed; a `ResponseError`'s is the upstream status line. Both are diagnostics for your logs, not -copy for your caller. Log the SDK error with its `cause`; return a message you wrote. +**Never put an SDK `message` on the wire.** It names the call — the method and the URL your service +dialled, path parameters included — and that is a diagnostic for your logs, not copy for your caller. +Log the SDK error with its `cause`; return a message you wrote — or, for a `4xx` the caller caused, the +API's own reason from `err.payload`. ## `instanceof` across the ESM/CJS boundary @@ -524,45 +596,44 @@ copy for your caller. Log the SDK error with its `cause`; return a message you w `require` in another — gets **two independent copies** of every error class, and `instanceof` across that boundary is `false`. It fails silently: a `catch` arm simply never matches. -If your process might do that, narrow on a value instead: +If your process might do that, narrow on a value instead — every member of the family carries `kind`: ```ts -// Family B, dialect-safe: -if (typeof err === "object" && err !== null && "kind" in err) { /* err.kind */ } - -// Family A, dialect-safe: -if (typeof err === "object" && err !== null && "status" in err && "payload" in err) { /* ... */ } +// The family, dialect-safe: +if (typeof err === "object" && err !== null && "kind" in err && "uri" in err) { /* err.kind */ } ``` `err.name` is also stable across copies (each class sets it from its own constructor name). ## Notes -- **No retries.** A failed call rejects once. `Retry-After` is not read, and nothing is retried — - including a 401 after a token invalidation. A retry wrapper is yours to build around `fetch`; see +- **Retries are on by default.** An idempotent call is retried on `408`, `429`, `500`, `502`, `503`, + `504`, a dropped + connection and a timeout, honouring `Retry-After`, before it rejects; a write is not, unless its + method is in `httpMethodsToRetry`. The error you catch is the last attempt's. See **typescript-configuration-resilience**. -- **A single `401` is not a permanent credential failure.** It invalidates the cached token without - resending; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a +- **A single `401` is not a permanent credential failure.** It invalidates the cached token and, by + default, is not re-sent; the caller sees that one `401`, and the *next* call acquires afresh. Do not tear down a client or alert on one. -- **No error logging.** `src/core/` contains no `console` call. Log in your `catch`, or in a wrapping - `fetch`. -- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on `ResponseError`; the `fetch` - `Response` is deliberately unreachable. +- **Failures are logged only when you turn logging on.** Logging is off by default; with + `ClientOptions.logging` on, the SDK writes a `warn` line for a non-`2xx` response and an `error` line + for a call that ends on a fault or on a credential it could not obtain (see + **typescript-configuration-resilience**). Neither is a substitute for handling the error: log what + your own code decided in your `catch`. +- **No raw `Response`.** `status` and `headers` are on `ApiResult` and on a `ResponseError`; the + `fetch` `Response` is deliberately unreachable. - **A missing credential is not an error.** An unset credential member sends no credential, so the - failure surfaces as a Family A `401` from the API rather than anything at construction — which reads - like an expired token instead of a config bug. This holds for composite schemes too: a partially - configured `all`/`any` scheme sends what it has rather than refusing, so there is no local "auth - could not be satisfied" rejection to catch. See **typescript-client-initialization**. -- **Some `SdkError`s are thrown by the constructor, not by a call** — no reachable `fetch`, or a - basic-auth username containing a colon. Wrap client construction too if it runs on a path that must - not crash. + failure surfaces as an `ApiError` `401` from the API rather than anything at construction — which + reads like an expired token instead of a config bug. This holds for composite schemes too: a + partially configured `all`/`any` scheme sends what it has rather than refusing, so there is no local + "auth could not be satisfied" rejection to catch. See **typescript-client-initialization**. - **SDK errors do not survive a worker or process boundary intact.** `structuredClone` and `postMessage` reduce an `Error` subclass to a plain `Error` — `name`, `message` and `cause` survive; - `status`, `payload`, `kind` and `rawBody` do not. Convert to your own serializable shape *before* you - post it, not after. + `kind`, `method`, `uri`, `status` and `payload` do not. Convert to your own serializable shape + *before* you post it, not after. ## Next -- Timeouts, cancellation, retry wrappers → **typescript-configuration-resilience** +- Timeouts, cancellation, the retry policy → **typescript-configuration-resilience** - Asserting error paths in tests → **typescript-testing** - Which arms an operation declares → `map/operations/{resource}.md`, the **Error arms** bullet diff --git a/plugins/adyen/skills/typescript/typescript-getting-started/SKILL.md b/plugins/adyen/skills/typescript/typescript-getting-started/SKILL.md index f490d771c..4cdd78949 100644 --- a/plugins/adyen/skills/typescript/typescript-getting-started/SKILL.md +++ b/plugins/adyen/skills/typescript/typescript-getting-started/SKILL.md @@ -13,22 +13,24 @@ This is the **SDK-specific** entry point. For general patterns that apply to any ## SDK identity -Verified against `package.json` and `sdk-map.md` of the generated package at version `72`. **Re-verify after a version bump** — this page is a snapshot, not a live read. +Verified against `package.json` and `sdk-map.md` of the generated package at version `72.0.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. | Fact | Value | | --- | --- | | API | Adyen | | Package name (what you install, and what you import) | `adyen` — **not on npm**; built from source (see *Install*) | | Import specifier | `adyen` — the package root is the **only** entry; deep imports do not resolve | -| Version | `72` (API spec version `72`) | +| Source repository | https://github.com/context-plugins/adyen-typescript-sdk (branch `main` — the ref this map documents) | +| Version | `72.0.0` (API spec version `72`) | | Client class | `AdyenClient` (`src/client.ts`) — one class, no sync/async split | -| Options type | `ClientOptions`, with `DEFAULT_CLIENT_OPTIONS` beside it (`src/client-options.ts`) | -| Client construction | `new AdyenClient(clientOptions: Partial = {})` — **every** field is optional, so `new AdyenClient()` compiles. Fields: `serverEnvironment` · `serverOptions` · `timeout` · `fetch` · `apiKeyAuth` · `basicAuth` · `clientKey`. `timeout` defaults to `60_000` ms | +| Options type | `ClientOptions` (`src/client-options.ts`) — types only, no resolver beside it | +| Client construction | `new AdyenClient(options: ClientOptions = {})` — the argument is optional, as is **every** field on it, so `new AdyenClient()` compiles. Fields: `serverOptions` · `retry` · `fetch` · `apiKeyAuth` · `basicAuth` · `clientKey`. `retry` is the `RetryOptions` policy; its defaults retry a GET, HEAD, PUT or OPTIONS call up to `3` times and bound each attempt by `retry.timeout` = `60_000` ms, and every one of them can be changed | | Auth | **API key (header)** — set `ClientOptions.apiKeyAuth`
**HTTP Basic** — set `ClientOptions.basicAuth`
**API key (query)** — set `ClientOptions.clientKey` | -| Environments | 1 environment (`ServerEnvironment.Test` *(default)*) × 29 server groups | -| Base-URL config | `serverOptions...baseUrl` (`src/servers.ts`) | -| Node floor | `>=20` (`engines.node`) | -| Runtime dependency | `zod` (`^3.25.0 \|\| ^4.0.0`), imported as `zod/v4-mini` — the only one | +| Environments | no selector — one environment, so none is emitted; 29 server groups | +| Base-URL config | `serverOptions..baseUrl` (`src/servers.ts`) | +| Log environment variable | `ADYEN_CLIENT_LOG` | +| Node floor | `>=20.3` (`engines.node`) | +| Runtime dependency | `zod` (`^4.1.0`), imported as `zod/v4-mini` — the only one | | Module format | dual ESM + CommonJS folder dialects (`dist/esm`, `dist/commonjs`) behind one export | | Typing | the package ships its own `.d.ts` and is generated under strict TypeScript. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Surface | 449 operations · 92 resources · 2046 models · 522 open enums · 21 unions · 420 per-operation error subclasses | @@ -37,22 +39,22 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install -This SDK is not published to npm, so the install comes straight from the repository the plugin records for it: +Install the package straight from the repository the plugin records for it, as it is not published to npm: ```bash npm install git+https://github.com/context-plugins/adyen-typescript-sdk#main ``` -That fetches everything the package's `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. +The installed package carries everything its `files` list packs — `src/`, `sdk-map.md` and the pages under `map/operations/` — so **every lookup on this page works as soon as the install finishes**. **Make sure the installed package is built**, as the source on GitHub is not pre-built. -Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20` (`engines.node`). +Do not vendor its `src/` into your project, point `tsconfig` `paths` at a throwaway clone, or import from `dist/` directly. Installing the package properly is what makes the `exports` map, the shipped `.d.ts` chain and the dual-dialect resolution behave the way the SDK expects — **and it is what puts the SDK map inside `node_modules`, which is where every lookup below reads it from**. Requires Node `>=20.3` (`engines.node`). ## Imports — one entry, and only one -**Every** public name is re-exported from the package root — the client, `ClientOptions`, `ServerEnvironment`, 2589 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). +**Every** public name is re-exported from the package root — the client, `ClientOptions`, 2589 model types with the schema value beside each, the error classes, and the runtime types (`ApiPromise`, `ApiResult`, `RequestOptions`, `RetryOptions`, `RequestRetryOptions`, `ErrorPayload`, `Declared`, `Schema`, `EnumSchema`, `Encoded`). ```ts -import { AdyenClient, ServerEnvironment, ResponseError, AdyenError } from "adyen"; +import { AdyenClient, ApiError, AdyenError } from "adyen"; import type { ClientOptions, AchDirectDebit } from "adyen"; ``` @@ -67,49 +69,47 @@ Things the specifier alone will not tell you: Under `verbatimModuleSyntax`, names carrying no runtime value (`ClientOptions`, every model type) must be imported with `import type`. Under `exactOptionalPropertyTypes`, **omit or spread** an absent optional field rather than assigning `undefined` to it. -## Environments +## Hosts -`ClientOptions.serverEnvironment` selects one environment for the whole client (`src/servers.ts`). `ServerEnvironment` is a `const` object with a derived union type — not a TypeScript `enum` — and unlike the model enums it is **closed**, so only its declared members are assignable. +This API declares one environment, so the SDK has **no environment selector** — there is no `ServerEnvironment` type and no field to set (`src/servers.ts`). The host below is the only one the SDK knows, and the override in the last column is the only way to reach another. -| Group | Environment | Base URL | Override at | -| --- | --- | --- | --- | -| `default` | `test` *(default)* | `https://checkout-test.adyen.com/v72` | `serverOptions.default.test.baseUrl` | -| `default1` | `test` *(default)* | `https://pal-test.adyen.com/pal/servlet/Payment/v68` | `serverOptions.default1.test.baseUrl` | -| `default2` | `test` *(default)* | `https://pal-test.adyen.com/pal/servlet/Recurring/v68` | `serverOptions.default2.test.baseUrl` | -| `default3` | `test` *(default)* | `https://pal-test.adyen.com/pal/servlet/Payout/v68` | `serverOptions.default3.test.baseUrl` | -| `default4` | `test` *(default)* | `https://pal-test.adyen.com/pal/servlet/BinLookup/v54` | `serverOptions.default4.test.baseUrl` | -| `default5` | `test` *(default)* | `https://pal-test.adyen.com/pal/servlet/StoredValue/v46` | `serverOptions.default5.test.baseUrl` | -| `default6` | `test` *(default)* | `https://ca-test.adyen.com/ca/services/DataProtectionService/v1` | `serverOptions.default6.test.baseUrl` | -| `default7` | `test` *(default)* | `https://balanceplatform-api-test.adyen.com/fx/api/v1` | `serverOptions.default7.test.baseUrl` | -| `default8` | `test` *(default)* | `https://pal-test.adyen.com/pal/services/TestCard/v1` | `serverOptions.default8.test.baseUrl` | -| `default9` | `test` *(default)* | `https://management-test.adyen.com/v3` | `serverOptions.default9.test.baseUrl` | -| `default10` | `test` *(default)* | `https://cal-test.adyen.com/cal/services/Account/v6` | `serverOptions.default10.test.baseUrl` | -| `default11` | `test` *(default)* | `https://balance-control-test.adyen.com/balance-control/api/v2` | `serverOptions.default11.test.baseUrl` | -| `default12` | `test` *(default)* | `https://cal-test.adyen.com/cal/services/Notification/v6` | `serverOptions.default12.test.baseUrl` | -| `default13` | `test` *(default)* | `https://balanceplatform-api-test.adyen.com/bcl/v2` | `serverOptions.default13.test.baseUrl` | -| `default14` | `test` *(default)* | `https://balanceplatform-api-test.adyen.com/btl/v4` | `serverOptions.default14.test.baseUrl` | -| `default15` | `test` *(default)* | `https://balanceplatform-api-test.adyen.com/capital/v1` | `serverOptions.default15.test.baseUrl` | -| `default16` | `test` *(default)* | `https://cal-test.adyen.com/cal/services/Fund/v6` | `serverOptions.default16.test.baseUrl` | -| `default17` | `test` *(default)* | `https://test.adyen.com/authe/api/v1` | `serverOptions.default17.test.baseUrl` | -| `default18` | `test` *(default)* | `https://kyc-test.adyen.com/lem/v4` | `serverOptions.default18.test.baseUrl` | -| `default19` | `test` *(default)* | `https://cal-test.adyen.com/cal/services/Hop/v6` | `serverOptions.default19.test.baseUrl` | -| `default20` | `test` *(default)* | `https://balanceplatform-api-test.adyen.com/a2aissuer-api/v1` | `serverOptions.default20.test.baseUrl` | -| `default21` | `test` *(default)* | `https://obgateway-test.adyen.com/obgateway/v1` | `serverOptions.default21.test.baseUrl` | -| `default22` | `test` *(default)* | `https://ca-test.adyen.com/ca/services/DisputeService/v30` | `serverOptions.default22.test.baseUrl` | -| `default23` | `test` *(default)* | `https://balanceplatform-api-test.adyen.com/btl/api/v4` | `serverOptions.default23.test.baseUrl` | -| `default24` | `test` *(default)* | `https://device-api-test.adyen.com/v1` | `serverOptions.default24.test.baseUrl` | -| `default25` | `test` *(default)* | `https://softposconfig-test.adyen.com/softposconfig/v3` | `serverOptions.default25.test.baseUrl` | -| `default26` | `test` *(default)* | `https://management-test.adyen.com/v1` | `serverOptions.default26.test.baseUrl` | -| `default27` | `test` *(default)* | `https://checkout-test.adyen.com/checkout/possdk/v68` | `serverOptions.default27.test.baseUrl` | -| `default28` | `test` *(default)* | `https://postfmapi-test.adyen.com/postfmapi/terminal/v1` | `serverOptions.default28.test.baseUrl` | +| Group | Base URL | Override at | +| --- | --- | --- | +| `default` | `https://checkout-test.adyen.com/v72` | `serverOptions.default.baseUrl` | +| `default1` | `https://pal-test.adyen.com/pal/servlet/Payment/v68` | `serverOptions.default1.baseUrl` | +| `default2` | `https://pal-test.adyen.com/pal/servlet/Recurring/v68` | `serverOptions.default2.baseUrl` | +| `default3` | `https://pal-test.adyen.com/pal/servlet/Payout/v68` | `serverOptions.default3.baseUrl` | +| `default4` | `https://pal-test.adyen.com/pal/servlet/BinLookup/v54` | `serverOptions.default4.baseUrl` | +| `default5` | `https://pal-test.adyen.com/pal/servlet/StoredValue/v46` | `serverOptions.default5.baseUrl` | +| `default6` | `https://ca-test.adyen.com/ca/services/DataProtectionService/v1` | `serverOptions.default6.baseUrl` | +| `default7` | `https://balanceplatform-api-test.adyen.com/fx/api/v1` | `serverOptions.default7.baseUrl` | +| `default8` | `https://pal-test.adyen.com/pal/services/TestCard/v1` | `serverOptions.default8.baseUrl` | +| `default9` | `https://management-test.adyen.com/v3` | `serverOptions.default9.baseUrl` | +| `default10` | `https://cal-test.adyen.com/cal/services/Account/v6` | `serverOptions.default10.baseUrl` | +| `default11` | `https://balance-control-test.adyen.com/balance-control/api/v2` | `serverOptions.default11.baseUrl` | +| `default12` | `https://cal-test.adyen.com/cal/services/Notification/v6` | `serverOptions.default12.baseUrl` | +| `default13` | `https://balanceplatform-api-test.adyen.com/bcl/v2` | `serverOptions.default13.baseUrl` | +| `default14` | `https://balanceplatform-api-test.adyen.com/btl/v4` | `serverOptions.default14.baseUrl` | +| `default15` | `https://balanceplatform-api-test.adyen.com/capital/v1` | `serverOptions.default15.baseUrl` | +| `default16` | `https://cal-test.adyen.com/cal/services/Fund/v6` | `serverOptions.default16.baseUrl` | +| `default17` | `https://test.adyen.com/authe/api/v1` | `serverOptions.default17.baseUrl` | +| `default18` | `https://kyc-test.adyen.com/lem/v4` | `serverOptions.default18.baseUrl` | +| `default19` | `https://cal-test.adyen.com/cal/services/Hop/v6` | `serverOptions.default19.baseUrl` | +| `default20` | `https://balanceplatform-api-test.adyen.com/a2aissuer-api/v1` | `serverOptions.default20.baseUrl` | +| `default21` | `https://obgateway-test.adyen.com/obgateway/v1` | `serverOptions.default21.baseUrl` | +| `default22` | `https://ca-test.adyen.com/ca/services/DisputeService/v30` | `serverOptions.default22.baseUrl` | +| `default23` | `https://balanceplatform-api-test.adyen.com/btl/api/v4` | `serverOptions.default23.baseUrl` | +| `default24` | `https://device-api-test.adyen.com/v1` | `serverOptions.default24.baseUrl` | +| `default25` | `https://softposconfig-test.adyen.com/softposconfig/v3` | `serverOptions.default25.baseUrl` | +| `default26` | `https://management-test.adyen.com/v1` | `serverOptions.default26.baseUrl` | +| `default27` | `https://checkout-test.adyen.com/checkout/possdk/v68` | `serverOptions.default27.baseUrl` | +| `default28` | `https://postfmapi-test.adyen.com/postfmapi/terminal/v1` | `serverOptions.default28.baseUrl` | Consequences to state on every contract sheet that touches configuration: -- Constructing the client with no options selects **`ServerEnvironment.Test`**, silently. -- ⚠ **This SDK declares exactly one environment**, so reaching any other host is a `serverOptions` override, not an environment value — `serverOptions: { default: { test: { baseUrl: "…" } } }`. Note the key is still `test`: the environment name and the host it points at are now decoupled, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the deployment's intent explicit where the client is built, and verify it. -- An override merges with the built-in default **per group-and-environment pair, key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it, and templates expand per request rather than once at construction. +- ⚠ **This SDK declares exactly one environment**, so it emits no `ServerEnvironment` at all and reaching any other host is a `serverOptions` base-URL override — `serverOptions: { default: { baseUrl: "…" } }`. Nothing in the options then records **which** deployment this is, which is exactly the shape of configuration that gets a live secret pointed at a test host or the reverse. Make the intent explicit where the client is built, and verify it. +- An override merges with the built-in default **key by key**; a `baseUrl` override replaces the template verbatim, template variable values are percent-encoded into it; server variables are filled in once, as the client is built, and only the path parameters expand per request. - Each operation is bound to one server group at generation time. A map block carries a **Server** bullet only when its group is not `default`. -- An environment value the SDK does not know throws `SdkError` **synchronously out of the operation method** at the first call — not at construction — so `try`/`await` catches it but `.asApiResult()` and `.catch()` never see it. ## Auth pattern (3 schemes) @@ -135,7 +135,7 @@ Three more behaviours the type does not show: - **A credential may be a function.** Every field typed `TokenProvider` is re-read on **every** request with no caching, so a key can rotate without rebuilding the client. An empty string counts as absent; a function counts as present without being invoked. - **Composition is emitted, not configured.** Where the spec puts two schemes in one requirement the SDK sends **both**; where it lists alternatives it sends the **first configured** one, in the order the **Auth** bullet prints them. -- **A 401 invalidates, it does not retry.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. +- **A 401 invalidates the cached credential.** On a 401 (401 only, not 403) the SDK clears whatever that operation's scheme had cached, so the *next* call re-acquires; the current request still rejects. See `typescript-authentication` for the full picture. @@ -240,7 +240,7 @@ Resources are **memoized lazy getters** on the client (`client.`). Their c 18 operations are methods on the client itself: `client.abortRequest` · `client.adminRequest` · `client.balanceInquiryRequest` · `client.cardAcquisitionRequest` · `client.cardReaderApduRequest` · `client.diagnosisRequest` · `client.displayRequest` · `client.enableServiceRequest` · `client.getTotalsRequest` · `client.inputRequest` · `client.loginRequest` · `client.logoutRequest` · `client.paymentRequest` · `client.printRequest` · `client.reconciliationRequest` · `client.reversalRequest` · `client.storedValueRequest` · `client.transactionStatusRequest`. -Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal }`) second — and returns `ApiPromise`. +Every operation has the same call shape — `op(request, options?)`, one **flat, channel-blind** request object first and `RequestOptions` (`{ signal, retry, logging }`) second — and returns `ApiPromise`. ⚠ **The request type name is not uniformly `Request`.** A name that collides inside its namespace is promoted to `RequestParams` instead — no operation in this SDK takes that spelling at this version. Take the name from the operation's **Signature** bullet on its map page; never construct it from the method name. @@ -248,7 +248,7 @@ Every operation has the same call shape — `op(request, options?)`, one **flat, The SDK ships a generated map, and `package.json`'s `files` list includes it, so **installing the package gives you the map** — no clone is needed. It sits at the package root, the directory holding `package.json` and the `src/` tree: -- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the two error families with `ApiResult` and `.asApiResult()`, wire serialization for every channel, **the full enum table with every member and its wire value**, servers and auth, runtime and packaging, and the link table into the operations pages. +- **`sdk-map.md`** — the index: client construction with the full `ClientOptions` table, the *Not on this SDK* table, the one error family with `ApiResult` and `.asApiResult()`, wire serialization for every channel, servers and auth, runtime and packaging, and the link table into the operations pages. - **`map/operations/.md`** — one page per resource, one `###` block per operation, with bullets in the fixed order **Server**, **Signature**, **Wire** (verb and route), **Auth**, **Request body**, **SDK-sent**, **Returns**, **Error**, **Error arms** — then a **Fields** table giving every request field its channel, wire name, type, required flag and default, and a **Type sources** table naming the declaring file and schema value of every type the operation mentions. Locate the installed package before you rely on a lookup: @@ -257,11 +257,11 @@ Locate the installed package before you rely on a lookup: node -e "console.log(require.resolve('adyen/package.json'))" ``` -Failing that it is at `node_modules/adyen/`. **If the package is not installed, there is no map and no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. +Failing that it is at `node_modules/adyen/`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. A registry id is a claim on a shared index rather than proof of identity, so it can resolve to an unrelated package or to a release predating the map; the repository is the copy that is this SDK by construction. Read it there, and if neither route yields a map, mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Every `Source` path on the map is relative to that package root, so `src/models/.ts` opens as written from there — the package ships its `src/` tree, so the path resolves inside `node_modules/adyen/` exactly as the map writes it. An import specifier ending `.js` inside that source is the NodeNext spelling of the sibling `.ts` file. -**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, enum values, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable. The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* +**The map is the locator; the source files are the shapes.** Read the map first — signatures, routes, request fields with their channels and defaults, return types, error arms, and which file declares a type are all answered there without opening a single `.ts` file. Then open the one file the map names for what it deliberately does not carry: a model's members, whether each is required, optional or nullable, and an enum's members with their wire values — the `as const` object in the enum's own file (for an enum inside a model, the file the model imports it from). The map says so itself — *"Shapes live only in the source … Do not derive the path from the type name."* **`sdk-map.md` carries the invariants every operation block assumes, so read it before any `map/operations/` page**; the pages are written to be read beside it. And **silence means the default**: the index states what holds for every operation — the call shape, the flat channel-blind request object, the `ApiPromise` return, the default server group, no pagination and no streaming — and a block departs from one only by saying so. Take the default and move on rather than opening the source to confirm it. @@ -269,7 +269,7 @@ Every `Source` path on the map is relative to that package root, so `src/models/ ## Contract facts — the map first, then the source file -**Seven of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; the environments, base URLs and auth wiring; **and every enum's members with their wire values**, which `sdk-map.md` tabulates in full. +**Six of these are map lookups — don't open a source file for them:** an operation's signature; its request fields with channel, wire name, required flag and default; its return type; its error subclass and the arms with the status each covers; the `ClientOptions` fields and their defaults; and the environments, base URLs and auth wiring. The table below covers everything else, and the full body behind a map row. Paths are relative to `node_modules/adyen/`: @@ -279,13 +279,15 @@ The table below covers everything else, and the full body behind a map row. Path | The operation method body and the request it builds | `src/resources/.ts` | | The per-operation request and error types (merged namespace) | the `export namespace ` block at the foot of the same file | | Client construction, resource getters | `src/client.ts` | -| `ClientOptions` fields and `DEFAULT_CLIENT_OPTIONS` | `src/client-options.ts` | +| `ClientOptions` fields | `src/client-options.ts` | | Environments, base URLs, override merging | `src/servers.ts` | | Auth scheme wiring, token endpoint, credential placement | `src/auth-schemes.ts`, `src/core/auth/credentials.ts`, `src/core/auth/oauth2-strategies.ts` | -| The transport: timeout clamp, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | -| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/response-error.ts` | +| The transport: the attempt loop, `fetch` resolution, 401 invalidation, 2xx-vs-error split | `src/core/raw-client.ts` | +| Error classes and `ErrorKind` | `src/core/errors.ts`, `src/core/api-error.ts` | | `ApiPromise`, `ApiResult`, `.asApiResult()`, the `Symbol.species` behaviour | `src/core/api-promise.ts` | -| `RequestOptions` (it is `{ signal }` and nothing else) | `src/core/api-request.ts` | +| `RequestOptions` (it is `{ signal, retry, logging }` and nothing else) | `src/core/api-request.ts` | +| `RetryOptions` and its defaults, `RequestRetryOptions`, backoff, `Retry-After`, `RetryAttempt` | `src/core/retry.ts` | +| `LoggingOptions` and its defaults (`defaultLoggingOptions`), `RequestLoggingOptions`, `Logger`, `LogDetails` | `src/core/logging.ts` | | Schema decode/encode, `SchemaError`, `Encoded` | `src/core/validation/schema-error.ts` and its directory | | Wire serialization per channel | `src/core/param-value.ts`, `src/core/url.ts`, `src/core/headers.ts`, `src/core/params.ts` | | What an operation *means* — field semantics, coupling rules | `api-reference.md` at the package root | @@ -299,30 +301,38 @@ Keep lookups cheap — the rules that keep a session's context small: - **Never grep, glob or `find` the package to *locate* a type** — the map is the locator, and it says so. Grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a cross-cutting *shape* is a different question and is fine: "every field typed `unknown`", "every required-nullable member" are things nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool — record what it found on the sheet. - Trust the compiler over this page: if a name here ever fails to type-check, re-read the file the table above names and report the drift; never patch around it from memory. +## Sensitive data — check the request models before you log anything + +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's **Type sources** table names it) lists every field. If the scope touches such a field, four generator facts decide your logging posture (all in `typescript-configuration-resilience`): a JSON request body is logged **verbatim** when `logRequestBody` is on, with no redaction; a form body is masked only by deny-list; a per-call `RequestOptions.logging` level turns the request body on for that call, whatever the client set; and leaving `ClientOptions.logging` without a `logger` or a `level` arms the `ADYEN_CLIENT_LOG` environment variable, which turns logging and both header switches on with no code change and no deploy — though never the body. So on any build that can carry such a field: `logRequestBody` stays off, no call on those paths passes a per-call level, `ClientOptions.logging.level` is set explicitly in production, and your own diagnostics never echo a request body on those paths. + +The SDK never logs a response body, but your own code may. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. + ## Integration workflow — load the companion skill at each step Before you write the code for each step, load the named companion skill — even if you have already read the relevant file. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new AdyenClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `SdkError`, not the first call.) +1. **Client construction & lifetime** — load **typescript-client-initialization** before you write `new AdyenClient(…)`. (*The signature won't tell you:* every option is optional, so a client built with no arguments compiles and talks to the default environment with no credential; the client must be **long-lived and app-scoped**, never rebuilt per request, because the resource getters live on it; there is no `close()` or `dispose()` — it owns no pool, only a `fetch`; and when no `fetch` is reachable the **constructor** throws `ConfigurationError`, not the first call.) 2. **Authentication** — load **typescript-authentication** before you set credentials. The 3 schemes are `apiKeyAuth`, `basicAuth`, `clientKey` on `ClientOptions`. (*The signature won't tell you:* the field is optional — omit it and every request goes out unauthenticated with no failure at construction; and a 401 invalidates the cache without retrying the current call. Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **typescript-calling-endpoints** before the first `client..(…)` call. (*The signature won't tell you:* the request object is **flat and channel-blind** — a field named `body` *is* the whole request body and every other field is fanned out to path, query or header by the SDK, so nothing is nested by channel; **an omitted field that has a default is still sent, with that default**; **30 operations resolve to `undefined`**; and `.asApiResult()` must be called on the value the operation returned, because `ApiPromise` overrides `Symbol.species` and `.then()`/`.catch()` hand back a plain `Promise` with the method gone.) 4. **Models** — load **typescript-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* models are plain `type`s built from object literals — no constructor, no builder; `f?: T` means omit the key, while `f: T | null` is **required and nullable** and `null` is a distinct value; enums are **open** (`const` companion plus a union admitting `(string & {})`), so the schema validates the base type only and an unknown server value round-trips instead of throwing — use `.values` to test membership yourself; and every type has a schema companion usable in both directions.) -5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* there are **two disjoint families** — `ResponseError` and its per-operation subclasses for an API error status, and the `AdyenError` set (`ConnectionError`, `TimeoutError`, `AbortError`, `SdkError`, `SchemaError`, `AuthError`) for no usable response — and neither is `instanceof` the other, so a complete catch needs both arms; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `SchemaError`, not `ResponseError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) -6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — a failed call rejects once, so retry/backoff is entirely yours to build or deliberately omit; **there is no logging and there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the single extension point for all of it; `timeout` is client-wide with **no per-request timeout**, and a non-finite or non-positive value is not "no timeout" but a fallback to the transport's own ceiling; and a `fetch` replacement that drops `init.signal` makes both the timeout and every `RequestOptions.signal` inert.) -7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds a `ResponseError`-only test misses, `SchemaError` above all.) +5. **Error handling** — load **typescript-error-handling** before you write any `try/catch`. (*The signature won't tell you:* every operational failure is **one family**, `AdyenError`, narrowed on `err.kind` — `"api"` is an API error status, carried by `ApiError` and its per-operation subclasses — while four throwables sit outside it (`ConfigurationError` from the constructor, `SchemaError` from a codec called directly, a `TypeError` for a bug, and a caller abort's own `reason`), so a `catch` that tests the family has to rethrow the rest; **arm tags are schema-derived, not statuses** (see the sheet checklist below); a malformed 2xx body rejects with `DecodeError`, not `ApiError`, and `.asApiResult()` does not convert it; and a missing response field the schema permits is silently `undefined` rather than any error at all.) +6. **Configuration & resilience** — load **typescript-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — the default policy retries a GET, HEAD, PUT or OPTIONS call on `408`, `429`, `500`, `502`, `503` and `504` and on a dropped connection or timeout, and every one of those is a `RetryOptions` field you can change; a write is repeated only when `httpMethodsToRetry` names its method; `retry: { maxRetries: 0 }` turns it off, one call overrides it through `RequestOptions.retry`, and a `401` is not retried by default; `retry.timeout` bounds **one attempt**, up to its response headers, not the whole call, and `0` is a zero-length deadline while a value `setTimeout` cannot honour falls back to the default; **logging is off by default** — `ClientOptions.logging` turns it on with a `logger` or a `level`, and so does the `ADYEN_CLIENT_LOG` environment variable when code sets neither, but a request body is logged only when code sets `logRequestBody`, and then a JSON body **verbatim**, with no redaction; **there are no hooks, middleware or interceptors** — `ClientOptions.fetch` is the one transport extension point, `onRetry` being the only built-in callback; and a `fetch` replacement is called with the attempt's `Request` and no `init`, so one that does not forward that `Request`, or its `signal`, makes both each attempt's timeout and every `RequestOptions.signal` inert.) +7. **Testing** — load **typescript-testing** before you stub the SDK. (*The signature won't tell you:* the seam is **`ClientOptions.fetch`**, not the client class and not the resource classes — whose constructors take unexported engine internals, so they cannot be instantiated in a test; stub bodies in **wire shape** and let the SDK decode them; assert on the request the SDK actually built, headers included; and cover the failure kinds an `ApiError`-only test misses, `DecodeError` above all.) ## What a contract sheet must carry for this SDK Beyond the usual signatures and model members, a contract sheet for the Adyen TypeScript SDK is incomplete without these, because each one is a decision the implementer cannot make correctly from the signature alone. -1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment, `ServerEnvironment.Test`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. +1. **Which host each deployment talks to**, and where that is set. This SDK declares one environment and emits no `ServerEnvironment`, so any other host is a `serverOptions` base-URL override rather than an environment member — give the override path on the sheet. 2. **30 operations resolve to `undefined`** — `await` gives you nothing to inspect, so **`.asApiResult()` is the only way to observe their status and headers** — decide the mode at write time, not by retrofit. 3. **The exact request type name per operation**, taken from the **Signature** bullet. -4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. Any caller-supplied idempotency or request-id field is the ONLY idempotency this SDK has: it injects none and `RequestOptions` is `{ signal }` only. +4. **Every request field with its channel, wire name and default**, because the request object is flat and channel-blind and the SDK fans fields out. An omitted field that has a default is still sent with that default, so a defaulted header shapes the response whether or not the sheet mentions it. The generator injects an `Idempotency-Key` on every non-GET operation that does not declare that header itself — minted once per call and invisible to you. Every retry of that call re-sends the **same** key, which is what makes naming a write in `httpMethodsToRetry` safe where the provider deduplicates on it; but the next call mints a new one, so a call your own code repeats is two submissions. Any caller-supplied idempotency or request-id field is the only idempotency that spans calls, and `RequestOptions` is `{ signal, retry, logging }` only. 5. **Required vs optional vs required-nullable** for every model member the task sets — `f: T` required, `f?: T` omit the key, `f: T | null` required and nullable. And that under `exactOptionalPropertyTypes` an absent optional is **omitted or spread**, never assigned `undefined`. -6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ResponseError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 420 of 449 operations declare typed error bodies; the rest reject with the base `ResponseError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, and only then does the first covering wildcard or range win. -7. **That a malformed or drifted 2xx body rejects with `SchemaError`, not `ResponseError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never a Family B failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. -8. **That the SDK performs no retries, no logging, no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where any of it can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. -9. **That `Event`, `File`, `Response` imported from this package are model types, not the globals of those names** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `AdyenError` for the same reason. -10. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. +6. **The error arms for each operation in scope, with the status each covers — and the warning that arm tags are schema-derived, not status codes.** Every operation rejects with its own `ApiError` subclass narrowed on `err.payload.kind`, and a tag comes from the arm's **body schema**: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. The same tag means different statuses on different operations, and the same status carries different tags — so a tag is only meaningful beside the arm table it came from, and a shared helper that switches on `kind` across operations is a bug. 420 of 449 operations declare typed error bodies; the rest reject with the base `ApiError`. Every operation also carries an always-present `"undeclared"` arm holding `rawBody: ArrayBuffer`, for which **matcher precedence** matters: an exact numeric status is looked up across the whole table first, then the first covering range, and last a `"default"` arm where the spec declared one — whose body, if it does not fit, falls to `"undeclared"` rather than throwing. +7. **That a malformed or drifted 2xx body rejects with `DecodeError`, not `ApiError`, in both response modes** — `.asApiResult()` converts an HTTP error status, never any other failure. Any sheet row for a call whose result is used must name the members the implementer has to assert on, because a thin or truncated body decodes without complaint and the hole surfaces later. +8. **The retry policy this integration needs.** Retrying is on by default — `ClientOptions.retry` sets it for the client and `RequestOptions.retry` overrides it per call — so the sheet says whether the defaults fit, which writes (if any) join `httpMethodsToRetry`, since a write is repeated only when the policy names its method, and the per-attempt timeout. +9. **The logging this integration needs.** Logging is off by default — `ClientOptions.logging` sets it for the client and `RequestOptions.logging` sets one call's level — so the sheet says whether it stays off and, where it is on, the level, the logger, and whether headers and the request body are logged: a JSON request body is logged verbatim, with no redaction. +10. **That the SDK performs no pagination and no streaming at all**, and that `ClientOptions.fetch` is the one seam where either can be added — so whatever the task needs there is yours to build or deliberately omit. Say which. +11. **That `Event`, `File`, `Response` imported from this package are model types, not the globals of those names** — every sheet that references one should carry the alias it will be imported under. The error base is re-exported as `AdyenError` for the same reason. +12. A **REQUIRED READING** block naming the `typescript-*` companions that govern the steps, with inline `MUST load` pointers. diff --git a/plugins/adyen/skills/typescript/typescript-integrate-adyen/SKILL.md b/plugins/adyen/skills/typescript/typescript-integrate-adyen/SKILL.md index d9d5c127e..f61faf943 100644 --- a/plugins/adyen/skills/typescript/typescript-integrate-adyen/SKILL.md +++ b/plugins/adyen/skills/typescript/typescript-integrate-adyen/SKILL.md @@ -1,6 +1,6 @@ --- name: "typescript-integrate-adyen" -description: "MANDATORY FIRST STEP for Adyen TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Adyen in TypeScript/Node — abort request, admin request, balance inquiry request, card acquisition request, card reader apdu request, diagnosis request, or when the Adyen TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `adyen` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (adyen-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares only ServerEnvironment.Test, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." +description: "MANDATORY FIRST STEP for Adyen TypeScript SDK work in a TypeScript or JavaScript project — load this BEFORE you write any code; TypeScript SDK ONLY, never load it for any other language. Applies when asked to integrate Adyen in TypeScript/Node — abort request, admin request, balance inquiry request, card acquisition request, card reader apdu request, diagnosis request, or when the Adyen TypeScript SDK errors, fails to compile, or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load typescript-getting-started, confirm `adyen` actually resolves and read the SDK map that ships inside it before any lookup, (2) the exact plan-file path (adyen-plan.md at the repo root) and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every typescript-* companion skill the sheet names, (4) the host decision, which this SDK forces once and irreversibly — it declares one environment and no ServerEnvironment, so any other host is a serverOptions base-URL override rather than an environment member, and (5) the memory ban, where every signature, request field, channel, header default, error arm and enum member comes from a lookup and never from recall or runtime introspection." --- # Adyen TypeScript SDK — Integration workflow (lookup layer + contract sheet) @@ -11,7 +11,7 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l - **`typescript-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (package name, version, Node floor, how it installs), the single import specifier and what the root exports, the server environments and the base-URL knob, the auth pattern, the resources, and a **file table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its package root — and because `package.json`'s `files` list includes them, **installing the package puts the map in `node_modules/adyen/`**. There is nothing to clone and no helper agent to delegate to. `typescript-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one resource page your operations live on. -- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('adyen/package.json'))"`. **If it is not installed there is no map and no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries the commands. +- **The installed package is the ground truth; the file table is only the locator.** Confirm it resolves before you rely on a lookup — `node -e "console.log(require.resolve('adyen/package.json'))"`. **If the resolve fails, or the directory it prints carries no `sdk-map.md`, the map is still in the SDK's own repository** — , branch `main`. Read it there, and only if neither route yields a map mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. This SDK is **not published to a registry**, so the install is a build from its source — . `typescript-getting-started`'s *Install* section carries what to run. - **Read scoped.** Search for the one symbol and read the lines around it rather than whole files, and never copy a design comment's rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures with the right request type name, every request field with its channel, wire name and default, required-vs-optional-vs-nullable members, the error arms per operation with the status each covers, and enum members for the operations in scope. `typescript-getting-started` ends with a checklist of what a sheet for this SDK is incomplete without — treat that as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a map page per field. @@ -23,14 +23,14 @@ You do the SDK lookups yourself: `typescript-getting-started` is your **lookup l ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/adyen-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `typescript-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Load `typescript-error-handling` before you write the plan too: table 1b.4 is decided from it. Then write the plan and its contract sheet to `/adyen-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, **module format — ESM or CJS, which changes how the SDK is imported and whether `instanceof` on its error classes is trustworthy**, and the runtime: Node, a bundled browser app, or an edge runtime) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - **establish the toolchain before you need it**: the package manager, whether `adyen` is already a dependency and at what version, the `tsconfig` flags that change what compiles here (`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `strict`), and the exact commands that run the project's build, type check, lint and tests. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself, since the map ships inside it; - a baseline run of the project's checks (`tsc --noEmit`, the test runner, the linter — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; -- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment (`ServerEnvironment.Test`), so **any other host is a `serverOptions` base-URL override rather than an environment value**; +- credentials/environment verification (per the task's secret-handling rules) — **including which host this deployment will actually talk to**, since this SDK declares exactly one environment, so it emits no `ServerEnvironment` at all and **any other host is a `serverOptions` base-URL override rather than an environment value**; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your credential may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; - setting up your task tracking. @@ -40,11 +40,85 @@ Never use the planning phase to get a "head start" on implementation: **creating **What the gate does not cover:** installing `adyen` into the project, installing the toolchain, and the files those writes produce (`node_modules/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all — the map lives inside the installed package — and they are the only writes permitted before the gate. Nothing you author by hand may change. +### Step 1b — The tables the contract sheet must carry (fill these in before you write any code) + +The per-operation rows describe **what each operation is**. The tables below carry what those rows cannot: a write that may reach the provider twice or whose outcome you may not learn, a rule that spans two operations, and a field nobody asked for. Fill in every one that applies before any code exists. + +#### 1b.1 — DURABLE WRITES + +One row in each table below per operation in scope that creates, sends or changes something on the provider's side; `none` if the scope has no such write. Every row follows one order: **claim → SDK call → record the result**. The claim is a row of its own, committed to a store this application owns *before* the call — not a field filled in afterwards on the record the write is about — so a second caller is stopped before it reaches the provider, and a lost answer leaves something to settle. A test that reads the store from inside the faked `fetch` shows the claim was there when the request left. + +**DUPLICATE CLAIMS** — what stops the same operation reaching the provider twice. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +⚠⚠ **What rejects the second one**: the store refusing the second claim — a unique constraint or a conditional write on a value every caller doing the same operation computes the same (the order being paid, the signup being welcomed), never on a value minted per attempt, which is unique by construction and refuses nothing. A read before the write, an in-process lock, or an assumption that one process runs lets both callers through. Release the claim when the provider refuses the call, so a later attempt is not blocked. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim, recognised by the driver's error code rather than its message; the losing caller then uses the record the winner wrote. + +The SDK sends an `Idempotency-Key` minted once per call, and its own retries resend that key — but a second click or a retry of your own is a new call with a new key, so the provider cannot join the two. The key goes beside the claim, never in place of it; where the operation declares its own key, send the reference stored on the claim (see `typescript-configuration-resilience`). + +**UNKNOWN OUTCOMES** — what settles a write the provider may have acted on without your code learning the answer. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ A `ConnectionError` or `TimeoutError` on a write is *unknown*, not *failed*: the provider may already have acted. Reporting a failure is not a legal entry, and neither is leaving the caller to check or retry. Settle it in that write's own `catch`: re-read by the reference you sent — found, settle from what the provider holds; not found, or the re-read itself fails, still unknown, never failed. Telling the caller the outcome is unknown comes after the re-read, never instead of it. If the provider offers no way to find the write, that is a **Blocker** in Assumptions & Blockers, not a row. + +A write that never left has nothing to re-read — a credential that could not be obtained (`AuthError`) or a value that would not encode (`EncodeError`) — and must reach a different outcome. Re-reading after every error is not the safe default: "any error might mean it went through" is false for a request that never left, so a refused credential gets its own outcome and no re-read. **The test that fails the connection** asserts the re-read request was made; a second test, a never-sent failure on the same write, asserts it was not. + +Write `TBD` in **Where in the code** and **The test that fails the connection** while you plan — every row. Once the code compiles, replace each `TBD` individually: the claim write, then the SDK call it precedes; the `catch`, then the re-read it makes. ⚠⚠ A `TBD` left behind, a name that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +#### 1b.2 — CROSS-OPERATION INVARIANTS + +**CROSS-OPERATION INVARIANTS** — copy this header into the contract sheet and add one row per value a caller supplies (an identifier, a code, a reference) whose legal set something other than that call defines: a list the provider returns, or records this application keeps. `none` if the task restricts no such value. ⚠⚠ The map never states these rules; derive them from the task. + +| Invariant | Where the legal set comes from | Enforced where | The test that proves it | +| --- | --- | --- | --- | + +⚠⚠ **The legal set is the one this task offers, not everything the account holds.** Asking the provider whether a value exists is not this check: the provider already rejects what exists nowhere, so a lookup of one record by its id enforces nothing new — internal records, test records and records meant for something else all exist, and all pass it. Check membership of the offered set itself, read in full (every page). If neither the provider nor this application can name that set, that is a **Blocker** in Assumptions & Blockers, not a row. + +The check runs before the write is attempted and before its **DUPLICATE CLAIMS** claim is inserted — a value rejected after the claim exists leaves the claim behind to block a later, legitimate attempt. **The test that proves it** asserts a value outside the set is rejected **and** the write is never called. + +#### 1b.3 — OPTIONAL REQUEST FIELDS + +For every write operation in scope, the contract sheet carries an **OPTIONAL FIELDS** table: one row per optional field on the request, and what **requires** it. + +| Field | What requires it | Set it? | +| --- | --- | --- | +| `{optionalField}` | nothing | ❌ leave it out | +| `{optionalField}` | the words in the task that supply it | ✅ set it from those words | +| `{optionalField}` | a named row of this sheet — e.g. the idempotency key a DUPLICATE CLAIMS row sends, or the reference an UNKNOWN OUTCOMES row searches by | ✅ set it, and name that row here | + +⚠⚠ **A field nothing requires is not yours to choose** — and "something requires it" means the task's own words or a row of this sheet, never your own sense that it looks sensible. Leaving it out *is* the decision: the provider's own default then applies, whether that is the account's configuration or the API's documented behaviour, and neither is yours to replace with a value you invented. + +⚠⚠ **This rule never removes a field another row requires.** An idempotency key, and the reference you look an unknown outcome up by, are required by the DUPLICATE CLAIMS and UNKNOWN OUTCOMES rows rather than by the task — the task will never mention either. Dropping one to satisfy this table is a defect, not compliance. And because a header, a query parameter and a body field all look alike on this SDK's flat request object, "it is only a header" is not a reason to leave a field off the table. + +⚠⚠ **The map does not state defaults, and its silence is not permission to pick one.** A member typed `field?: T` tells you the field may be omitted. It tells you nothing about what omitting it does. If you cannot state what the provider will apply when the field is absent, that is a reason to leave it absent, not a reason to fill it. + +**The test that proves it:** assert the request the SDK sent carries **no key your own code set without a row that requires it** — headers and query parameters included, not only the body. The SDK adds its own (authorization, content type, its defaults); those are not yours and need no row. A test that only checks the fields you meant to send cannot see an invented one. + +#### 1b.4 — WHEN THE API SAYS NO + +Add one table per operation that uses the caller's data — what they typed, IDs they supply, and data your app stores for them (their email, their name). An empty request body does not make a refusal yours: when the API refuses the data you sent for them, only its reason tells them what to change. An operation with no typed arms still gets one. + +| status | your app answers | the caller reads | test | +| --- | --- | --- | --- | +| a `4xx` other than 401 / 403 / 429 | the same `4xx` | the API's reason text from `err.payload` | fake it with a reason; assert the reason is in your response | +| 401 / 403 | 502 | a fixed message — our credentials | fake a 401; assert 502 and the fixed message | +| 429 | 503 | a fixed message — our quota | — | +| 5xx | 502 | a fixed message | — | +| no response, on a write | per UNKNOWN OUTCOMES | unknown — never "failed" | — | +| no response, on a read | 502 | a fixed message | — | + +Logging the reason is not returning it: log it **and** put it in the response. + ### Step 1c — Required reading (do this before you write any code) End the contract sheet with a **REQUIRED READING** block whose rows carry inline `MUST load ` pointers. **Load every `typescript-*` skill the sheet names, now, before you start implementing** — not lazily at the step that needs it. The sheet deliberately does *not* carry the how-to: it names the hazard and hands you the skill that resolves it, so an unloaded pointer is a gap in what you know, not a formality. -**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK has two disjoint families plus a decode failure that belongs to neither obvious branch); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. +**A fixed floor applies regardless of what the sheet says** — the sheet can add to it, never remove from it: `typescript-error-handling` always (every integration writes an error boundary, and this SDK's one error family is narrowed on its closed `kind`, with four throwables outside it that a family test has to rethrow); `typescript-client-initialization` before the client is constructed; `typescript-testing` before the first test file you create or edit, *including* a throwaway verification script that fakes `fetch`. A sheet row that excuses one of these ("out of scope", "no test suite requested") is not a substitute for loading it. These are API-agnostic usage skills; loading them is not a substitute for the lookup layer, and reading a file in the installed package does not remove the need to load the skill for that step. Contract *facts* still come only from your sheet or a fresh lookup. @@ -58,13 +132,15 @@ Full re-planning only on genuine scope change; for a single missing fact mid-imp ### Step 2 — Implement from the contract sheet 1. Read `adyen-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. -2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment — `ServerEnvironment.Test` — so a different host is a `serverOptions` override under a key still named after `test`: the environment name and the host it points at are decoupled, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. +2. **Decide which host this deployment talks to, explicitly, before the first call.** This SDK declares one environment and emits no selector for it, so a different host is a `serverOptions` base-URL override. Nothing in the options then records **which** deployment this is, which is precisely the shape of configuration that gets a live secret aimed at a test host or the reverse. Bind it to configuration and fail startup if the (host, credential) pair is not the one this deployment intends. 3. **Build the client once and hold it.** Resources are memoized lazy getters on it, so rebuilding one per request throws that state away. There is no `close()` — it owns a `fetch`, not a pool. 4. **Pick the response mode per call, deliberately.** Every operation returns `ApiPromise`: `await` it and an error status rejects, or call `.asApiResult()` **on the value the operation returned** for the non-throwing `ApiResult` — the only way to see the status and headers of a *success*, and therefore the only way to observe anything at all from the **30 operations that resolve to `undefined`** (every operation whose success declares no body — the map's **Returns** bullet names them). `ApiPromise` overrides `Symbol.species`, so `.then()`/`.catch()`/`.finally()` hand back a plain `Promise` with the method gone — the mode is chosen at write time, never retrofitted. 5. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, field names, channels, error arms, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 6. After every change: run the project's **type checker** (`tsc --noEmit`) as well as its tests. The package ships its own types and is generated under strict TypeScript, so a type error against it is a real contract violation, not noise — it is the compile-time backstop the whole map-first discipline leans on. **Treat a clean type check as the gate you do not skip.** If the project has no type check configured, run `tsc --noEmit` over the files you touched; if you genuinely cannot, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ResponseError`: a malformed body is `SchemaError`, a dropped connection `ConnectionError`, the client timeout `TimeoutError`, an aborted signal `AbortError`, a credential that could not be **obtained** `AuthError` — and an unknown `serverEnvironment` throws `SdkError` synchronously out of the operation method, where `.asApiResult()` and `.catch()` never see it. So an exception that does not look like an API error may still be one of the SDK's failure kinds. -8. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, and has no logging, hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did. +7. **Any error involving an SDK type or member** — `TS2339` (property does not exist), `TS2345`/`TS2322` (argument or assignment mismatch), `TS2551` (did you mean), `TS2307` (cannot find module — usually a deep import), `TS2739`/`TS2741` (missing required members), or an unexpected rejection at runtime — → go back to `typescript-getting-started`'s map section and file table, and read the one page or file that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember which failures are **not** `ApiError`: a malformed body is `DecodeError`, a value that would not encode `EncodeError`, a dropped connection `ConnectionError`, an attempt's timeout `TimeoutError` (both only after any retries are spent), a credential that could not be **obtained** `AuthError` — every one of them in the `AdyenError` family, told apart by `err.kind`. So an exception that does not look like an API error may still be one of the SDK's failure kinds. +8. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +9. **Observability and sensitive data.** Logging is **off by default** and is configured through `ClientOptions.logging`: say whether the integration turns it on, at what level, and which correlation id from the provider's error bodies reaches your own logs. A JSON request body is logged **verbatim** when `logRequestBody` is on, and a form body is masked only by deny-list, so a key you have not listed prints in the clear. If the scope carries data you would not want in a log — each request model's declaring file lists every field in scope — `logRequestBody` stays off, no call on those paths passes a per-call level, which turns the body on, **and** `ClientOptions.logging.level` is set explicitly, so the `ADYEN_CLIENT_LOG` environment variable cannot switch logging on from outside the code (see `typescript-getting-started`). +10. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so if the task needs a different policy, tune it through `ClientOptions.retry` rather than wrapping the call. The SDK has no hooks or middleware, so anything the task needs there is yours to build on the `ClientOptions.fetch` seam or to deliberately omit — say which you did for both. ### Step 3 — Answering pure questions @@ -74,7 +150,7 @@ A standalone Adyen question with no code change: look it up in `typescript-getti - **Always load `typescript-getting-started` first.** It is your lookup layer, not optional background. (The `typescript-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, request fields, channels, error arms and enum members come from the sheet or a fresh lookup. - **Never write any Adyen or SDK fact from memory** — every signature, field name, wire header, enum member, and error arm in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"apiError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. +- **Never switch on an error arm tag across operations.** A tag is derived from the arm's **body schema**, never from its status: an arm whose body is a direct model reference is named after that model in lower camel (`"validationError"`), and every other body — a primitive, an array, a map, or no content — is named `"error{Status}"` (`"error400"`, `"error4XX"`, `"errorDefault"`), with a numeric suffix on the second of two arms that would otherwise land on the same name. Tags are resolved **per operation**, so the same tag covers different statuses on different operations — a shared handler keyed on `err.payload.kind` is a bug. Read the status from `err.status`, and narrow on `kind` only against the arm table of that one operation. - **Don't web-search Adyen topics to find an implementation detail** — the installed package's own map and source are the ground truth, and the file table tells you where to look. Public Adyen docs describe the HTTP API, not this SDK's generated surface, and the two disagree on names, on which fields are required, and on what the SDK sends by default. - **Never introspect the SDK at run time to discover its shape.** `Object.keys`, logging a client instance, or a REPL poke is the TypeScript-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it invites `#`-private fields and unexported engine types into your code. Read the map, then the file it names. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Do not derive the path from the type name"*); grep only *inside* the file its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every field typed `unknown`", "every required-nullable member" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `src/models/` is the right tool for them — record what it found on the sheet. diff --git a/plugins/adyen/skills/typescript/typescript-models/SKILL.md b/plugins/adyen/skills/typescript/typescript-models/SKILL.md index f0f3b83bc..ca6fed292 100644 --- a/plugins/adyen/skills/typescript/typescript-models/SKILL.md +++ b/plugins/adyen/skills/typescript/typescript-models/SKILL.md @@ -68,23 +68,24 @@ const ok: {Model} = { id: "1" }; // const also: {Model} = { id: "1", ...(label !== undefined ? { label } : {}) }; // conditional ``` -At run time an explicit `undefined` is dropped from the JSON anyway, so that one is a type-level -constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different +At run time an explicit `undefined` is dropped from the JSON anyway (a property with a spec default +sends its default instead, below), so that one is a type-level constraint. `null` is not: a nullable field set to `null` sends JSON `null`, which is a different request from an absent key — the distinction that matters on a PATCH. -**A spec-level default on a model property is not emitted.** Defaults are applied to an operation's -*request fields* (the `Default` column of its **Fields** table), never to a property inside a model, -so an omitted optional stays absent rather than being filled in for you. +**A model property with a spec default is optional in the type and `s.defaulted(...)` in its schema.** +Omitting it, or passing `undefined`, still sends the default (on a partial update, send its current value +instead), and a response that omits it reads back as the default. ### Reading one back -An absent optional is simply `undefined` — there is no sentinel value to import and nothing to -compare against, so `?.` and `??` are the whole toolkit, and `!== undefined` is the precise test when +An absent optional is simply `undefined` (one with a spec default reads back that default) — there +is no sentinel value to import and nothing to +compare against, so `?.` and `??` are the whole toolkit, and, on a property without a spec default, `!== undefined` is the precise test when "present but empty" and "absent" are different facts. Under `noUncheckedIndexedAccess` an array index is `T | undefined` too, so guard `items[0]` before reaching through it. ⚠ **A response type whose spec marks nothing required decodes an empty body without complaint** — -every property reads back `undefined` and nothing throws. A truncated or partial body is therefore +every property reads back `undefined`, or its default, and nothing throws. A truncated or partial body is therefore not an error, it is a model full of `undefined`; check the properties you actually depend on. A property that is required on a request type and optional on the response type is normal and intentional — two schemas for one concept — so never infer the response shape from the request shape. @@ -110,8 +111,9 @@ An `anyOf` becomes a bare union with nothing to switch on — `export type {Unio — and its schema tries the arms in **declaration order**, first match winning, so overlapping arms are resolved by order. -⚠ **A payload matching no arm throws.** Both kinds end in a `SchemaError` mid-decode — an -unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no +⚠ **A payload matching no arm throws.** Both kinds fail the decode — through an operation as a +`DecodeError` whose `cause` is the `SchemaError`, through a schema you call directly as the +`SchemaError` itself — an unrecognized tag has no arm to dispatch to, and an undiscriminated union that matches nothing has no arm to fall back on. You do not get a null union or an unset variant. A provider that adds a variant your SDK predates turns into a decode failure on a call that was previously fine, so a union field is a place to expect regeneration pressure. See **typescript-error-handling** for where that surfaces in @@ -160,7 +162,7 @@ convert. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -183,12 +185,12 @@ convert. fail; and a **`s.dateOnly()` field is a `string`**, so `new Date(...)` there is a type error and formatting `"YYYY-MM-DD"` is yours to do. -- **Every numeric kind is a `number`.** `int32`, `int64`, `float`, `double` and a big-decimal all map - to `number`, checked with `s.number()`; the model's property type is the source of truth and it - never says which. An `int64` past `Number.MAX_SAFE_INTEGER` has already lost precision in - `JSON.parse` before any schema sees it — the engine's `bigint` path is on the parameter serializer, - not on model properties — so treat a large-integer field as something to verify against the - provider rather than as a safe round-trip. +- **Every numeric kind is a `number`; the schema says which check runs.** `int32` and `int64` are + checked with `s.int()`, which rejects a fraction and anything outside the safe-integer range. + `float`, `double` and a big-decimal are checked with `s.float64()`, which rejects `NaN` and + `±Infinity`. Neither checks the `int32` or `float` range. An `int64` past + `Number.MAX_SAFE_INTEGER` is rejected rather than rounded, so treat a large-integer field as + something to verify against the provider rather than as a safe round-trip. - **There is no decimal type.** A `number` is an IEEE-754 double, so a spec that models money as a number hands you binary floating point and the usual accumulation errors. Many APIs instead model @@ -208,9 +210,11 @@ const body: {Model} = { payload: new TextEncoder().encode("hello") }; const text = new TextDecoder().decode(model.payload); ``` -That is for base64-in-JSON fields only. It is **not** file upload: the engine builds empty, JSON, -form-urlencoded and text bodies and nothing else — there is no multipart or raw-binary request body -at all (see **typescript-calling-endpoints**). +That is for base64-in-JSON fields only, and it is **not** the file-upload carrier. The engine does +send raw-binary and multipart bodies, but a file there is a `FileInput` — bytes the transport frames +rather than a value a schema encodes — and the two deliberately never unify: a decoded `Uint8Array` +field is not assignable to a file field, and `new Uint8Array(value)` is the one-copy bridge (see +**typescript-calling-endpoints**). ## What the schema checks — and what it does not @@ -232,7 +236,8 @@ a rejected request costs a round-trip and an error path — write it at your own constraints from `api-reference.md`, which does carry the API's documented contract. What the schema *does* check, it checks in **both** directions: encoding runs before the request is -built, so an invalid model throws `SchemaError` and **nothing is sent**. Read that failure as the SDK +built, so an invalid model rejects the call with an `EncodeError` — its `cause` the `SchemaError` +naming the field — and **nothing is sent**. Read that failure as the SDK catching your mistake at the point you made it, not as an upstream fault — the compiler catches the shape and the schema catches the values, and between them very little reaches the network wrong. In production, catch it at the boundary where *you* assemble a payload from external input and treat it @@ -268,8 +273,8 @@ if ({enum}Schema.values.includes(received)) { /* a member this SDK knows */ } There is no coercion helper — no `fromValue`, no lookup function — because a member *is* its wire value: `{Enum}.Active === "active"`, `` `${{Enum}.Active}` `` is `"active"`, and a value read off a response compares directly against the member. Member *names*, on the other hand, are generated -identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take both -columns from the **Enums** table in `sdk-map.md`. +identifiers rather than wire values — a number enum's members are named `_0`, `_1`, … — so take the +names and their values from the `as const` companion in the enum's own file. ## JSON key renaming happens invisibly @@ -343,4 +348,4 @@ schema surface, and the failures each date codec produces. ## Next - Pass models to an operation → **typescript-calling-endpoints** -- `SchemaError`, its message and `rawBody` → **typescript-error-handling** +- `SchemaError`, and the `DecodeError` or `EncodeError` it arrives on → **typescript-error-handling** diff --git a/plugins/adyen/skills/typescript/typescript-models/reference.md b/plugins/adyen/skills/typescript/typescript-models/reference.md index 11f4d332e..e6dc98c68 100644 --- a/plugins/adyen/skills/typescript/typescript-models/reference.md +++ b/plugins/adyen/skills/typescript/typescript-models/reference.md @@ -5,7 +5,7 @@ Condensed reference for the shapes in **typescript-models**. The schema combinat reached through the `s` namespace inside generated model modules. `s` re-exports `zod/v4-mini` (`string`, `number`, `boolean`, `array`, `record`, `union`, `nullable`, `optional`, `literal`, `unknown`) plus the SDK's own entries (`object`, `enumOf`, `discriminatedUnion`, `of`, `lazy`, -`optionalNullable`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). +`optionalNullable`, `defaulted`, `dateTime`, `rfc1123DateTime`, `unixSecondsDateTime`, `dateOnly`, `bytes`). Confirm concrete type and field names in `src/models/`. ## The two exports per model @@ -39,9 +39,10 @@ object, and mutating it changes nothing on the wire by itself. | `time` | `string` | `s.string()` — unchecked | | `date` | `string` | `s.dateOnly()` | | `date-time` (ISO 8601 / RFC 1123 / unix) | `Date` | `s.dateTime()` / `s.rfc1123DateTime()` / `s.unixSecondsDateTime()` | -| int32, int64, float, double, big-decimal | `number` | `s.number()` | +| int32, int64 | `number` | `s.int()` | +| float, double, big-decimal | `number` | `s.float64()` | | boolean | `boolean` | `s.boolean()` | -| binary, file | `Uint8Array` | `s.bytes()` (base64) | +| binary, file — as a declared field | `Uint8Array` | `s.bytes()` (base64) | | a value the spec fixed | that literal type | `s.literal(value)` | | free-form object | `Record` | `s.record(s.string(), s.unknown())` | | array of `T` | `T[]` | `s.array(...)` | @@ -58,6 +59,7 @@ folded onto `unknown`, which already admits `null`. | --- | --- | --- | | `f: T` | the entry itself | always provide | | `f?: T` | `s.optional(...)` | **omit the key** (not `f: undefined`) | +| `f?: T` | `s.defaulted(...)` | omitting it sends the spec default; set the value you mean | | `f: T \| null` | `s.nullable(...)` | provide `null` to send JSON `null` | | `f?: T \| null` | `s.optionalNullable(...)` | omit, or provide `null` | @@ -66,13 +68,13 @@ folded onto `unknown`, which already admits `null`. const body: {Model} = { id, ...(note !== undefined ? { note } : {}) }; ``` -Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON — +Run time is more forgiving than the type system — an explicit `undefined` is dropped from the JSON, except where a spec default is sent instead — but do not rely on it: the SDK's own tsconfig sets `exactOptionalPropertyTypes` (and `verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `strict`), and a consumer that does the same will not compile. -`s.defaulted(entry, value)` exists but belongs to an operation's request fields, not to model -properties; a model property never carries a default. +`s.defaulted(entry, value)` marks a model property or a request field with a spec default; the SDK fills +the default on encode and decode. ## Deriving a changed copy @@ -88,10 +90,10 @@ SDK does not model. ## Reading a response model -- **An absent optional is `undefined`.** No sentinel, no wrapper, so `?.` / `??` are enough, and - `!== undefined` is the precise test where "absent" and "present but empty" differ. +- **An absent optional is `undefined`**, unless it has a spec default. No sentinel, no wrapper, so `?.` / `??` are enough, and, + without a default, `!== undefined` is the precise test where "absent" and "present but empty" differ. - **A response type whose spec marks nothing required accepts `{}`.** `{model}Schema.decode({})` - succeeds and every property is `undefined`; a truncated body raises nothing. Check the properties + succeeds and every property is `undefined` or its default; a truncated body raises nothing. Check the properties you depend on. - **Required on the request, optional on the response is normal.** Two schemas, one concept — read each from its own place in the map rather than assuming they match. @@ -124,10 +126,11 @@ type EnumSchema = Schema & { readonly values: readonly T[] }; Both directions throw `SchemaError` on mismatch and produce nothing partial. The message is prefixed `Wire value could not be decoded.` or `Type could not be encoded for the wire.`, followed by -`path: expected X, received Y` per issue (`` where there is no path); `.rawBody` carries the -offending value, `.kind` is `"schema"`, and `.cause` is the underlying zod error with the structured -issue list. Request encoding runs before the HTTP call, so an encode failure means **nothing was -sent**. +`path: expected X, received T` per issue — `T` the type that arrived, never the value (`` +where there is no path). It keeps no copy of the offending value; `.cause` is the underlying zod error +with the structured issue list. Through an operation it arrives one level down, on the `cause` of a +`DecodeError` or an `EncodeError`. Request encoding runs before the HTTP call, so an encode failure +means **nothing was sent**. ## Key renaming @@ -157,7 +160,7 @@ export const {enum}Schema = s.enumOf<{Enum}>({Enum}); | --- | --- | | Type | **open** — the `(string & {})` / `(number & {})` tail admits any value of the base type | | Wire value | the member's value, verbatim (`"active"`, or `1` for a number enum) | -| Decoding | checks the **base type only** — `s.enumOf` builds `s.string()` or `s.number()` from the members and validates nothing else, so an unknown member is accepted | +| Decoding | checks the **base type only** — `s.enumOf` builds a string or a number check from the members and validates nothing else, so an unknown member is accepted | | Known set | `{enum}Schema.values` — `readonly {Enum}[]`, in declaration order | | Member names | generated identifiers; a number enum's members are `_0`, `_1`, … | @@ -216,7 +219,8 @@ export const {union}Schema = s.discriminatedUnion<{Union}>("{wire_tag}", { alias spells what arrives. - A polymorphic base (an object schema that `allOf` subtypes with a discriminator derive from) is emitted here **as a discriminated union**, not as a base type. There is nothing to upcast to. -- ⚠ A tag matching no arm is a `SchemaError`, not an unset variant. +- ⚠ A tag matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` through + an operation — not an unset variant. ```ts switch (value.{tag}) { @@ -237,7 +241,8 @@ export const {union}Schema = s.of<{Union}>(s.union([s.string(), s.lazy(() => {mo so overlapping arms resolve by order. - Build by passing a value of any arm directly. There is no wrapper and no factory. - A union the spec left unconstrained degrades to `unknown`. -- ⚠ A value matching no arm is a `SchemaError`. +- ⚠ A value matching no arm is a decode failure — a `SchemaError`, on a `DecodeError`'s `cause` + through an operation. ## Dates @@ -273,8 +278,9 @@ body.payload = new TextEncoder().encode("hello"); // encoded to base64 on const text = new TextDecoder().decode(model.payload); // decoded from base64 for you ``` -Base64-in-JSON fields only. Non-base64 input fails on decode. The engine has no multipart or -raw-binary request body, so this is not a file-upload carrier. +Base64-in-JSON fields only. Non-base64 input fails on decode. This is not the file-upload carrier: +a raw-binary body and a multipart file part both take a `FileInput`, which is framed rather than +encoded, and a decoded `Uint8Array` is deliberately not assignable to one. ## Collections @@ -286,7 +292,7 @@ raw-binary request body, so this is not a file-upload carrier. | Value | On the wire | | --- | --- | -| omitted / `undefined` | key absent | +| omitted / `undefined` | key absent, or the spec default if it has one | | `null` on a nullable field | `null` | | `[]` | `[]` | | `{}` | `{}` | @@ -314,8 +320,9 @@ has no visible effect at all. A key you need but the type lacks means the spec d ## What is never validated -`s.string()` carries no format check and no length or pattern check; `s.number()` carries no range or -`multipleOf` check; arrays carry no `uniqueItems` check. The generator does not transcribe those +`s.string()` carries no format check and no length or pattern check; `s.int()` and +`s.float64()` carry no `minimum`, `maximum` or `multipleOf` check; arrays carry no `uniqueItems` check. +The generator does not transcribe those constraints, so there is nothing to read and nothing to invoke — they are enforced only by the provider, as a `400`. `api-reference.md` carries the documented contract; put your own guard at your own boundary if you want one before the round-trip. diff --git a/plugins/adyen/skills/typescript/typescript-testing/SKILL.md b/plugins/adyen/skills/typescript/typescript-testing/SKILL.md index 2d44df9b9..2475f4097 100644 --- a/plugins/adyen/skills/typescript/typescript-testing/SKILL.md +++ b/plugins/adyen/skills/typescript/typescript-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-testing -description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated TypeScript SDK — which seam to fake (`ClientOptions.fetch`, or an interceptor at the global-fetch layer), stubbing bodies in wire shape, asserting the request the SDK actually built, covering the error and decode-failure paths, turning the on-by-default retry policy off (or testing it deliberately), and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic TypeScript SDK @@ -31,17 +31,20 @@ Substitute your own `{Api}Client` and operation names as well. mocking library — just a function: ```ts -type Call = { url: URL; init: RequestInit }; +type Call = { url: URL; request: Request; body: string | undefined }; -function stubFetch(responder: (url: URL, init: RequestInit) => Response | Promise) { +function stubFetch(responder: (url: URL, request: Request) => Response | Promise) { // Every request, in order — the token fetch appends too, so this is what you count. const calls: Call[] = []; - // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. + // Typed as `typeof fetch` so a signature drift is a compile error, not a runtime surprise. The SDK + // calls it with one `Request` and no `init`; wrapping both in a `Request` also accepts a direct call. const fetch: typeof globalThis.fetch = async (input, init) => { - const call = { url: new URL(String(input)), init: init ?? {} }; + const request = new Request(input, init); + const body = request.body === null ? undefined : await request.clone().text(); + const call = { url: new URL(request.url), request, body }; calls.push(call); - return responder(call.url, call.init); + return responder(call.url, request); }; return { fetch, calls, last: () => calls[calls.length - 1]! }; @@ -71,11 +74,24 @@ Wire it in and you have a real client with no network: ```ts function clientReturning(...responses: Response[]) { const stub = stubFetch(queue(...responses)); - const client = new {Api}Client({ fetch: stub.fetch, {scheme}: { /* ... */ } }); + const client = new {Api}Client({ + fetch: stub.fetch, + retry: { maxRetries: 0 }, // one request per call — see *Notes* + {scheme}: { /* ... */ }, + }); return { client, stub }; } ``` +**`retry: { maxRetries: 0 }` is load-bearing.** Retries are on by default, so without it a stubbed +`503` on a `GET` is fetched four times across about 7 s of backoff, and a queue holding one response +runs out on the second attempt. Leave it on every test client that is not *about* retrying. + +**Logging is off unless the environment turns it on.** A test client with no `logging` reads +`{APICLIENTTYPENAME}_LOG` (the client class name in upper snake case, plus `_LOG`) like any other, so a CI job +that exports it fills the test output with request lines. Set `logging: { level: "off" }` where that +matters, or pass a `logger` that records its calls when the test is *about* what gets logged. + `Response` is global on Node 18+. Credentials can be dummy values, or omitted entirely — nothing validates them when the transport is stubbed, unless the test is *about* the credential. @@ -101,9 +117,9 @@ actually put on the wire: field's schema combinator; - **every required field present.** -Get a required one wrong and the call rejects with `SchemaError` before your assertion runs — which -reads as a mysterious failure until you read the message, which names the failing path and the -expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are +Get a required one wrong and the call rejects with `DecodeError` before your assertion runs — which +reads as a mysterious failure until you read its `cause`, a `SchemaError` whose message names the +failing path and the expected type exactly. Get an **optional** one wrong and nothing fails at all: model schemas are **loose**, so an unrecognized key is preserved rather than rejected, the member reads back `undefined`, and the test passes for the wrong reason. Two shortcuts close that gap: @@ -130,7 +146,8 @@ it("returns the decoded model", async () => { ``` An operation whose success type is `undefined` needs a genuinely **empty** body — `emptyResponse()`. -A non-empty one raises `SchemaError` even on a `204`. +A response with a body makes the call fail with `DecodeError`; to test that, stub the body on a `200` — +`new Response(body, { status: 204 })` throws `TypeError`. ## Test an error path @@ -163,16 +180,17 @@ it("surfaces the declared 400 arm", async () => { The error body is decoded through **its** schema, so it is wire-shaped on the same terms as a success body. -**Case B — the operation declares no error bodies.** It rejects with the base `ResponseError`, whose -payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body: +**Case B — the operation declares no error bodies.** It rejects with the base `ApiError`, whose +payload is always the `"undeclared"` arm — **raw bytes**, not a decoded body. Its `kind` is typed +`string`, so test for `rawBody` to reach the bytes: ```ts -import { ResponseError } from "{package-name}"; +import { ApiError } from "{package-name}"; -const e = err as ResponseError; +const e = err as ApiError; expect(e.status).toBe(422); expect(e.payload.kind).toBe("undeclared"); -expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); +if ("rawBody" in e.payload) expect(new TextDecoder().decode(e.payload.rawBody)).toContain("bad input"); ``` **Take the arm union from the contract sheet per operation.** `"undeclared"` is reachable in Case A @@ -183,6 +201,10 @@ nothing about the typed body you meant to check. Use `try`/`catch` rather than a rejection matcher: matcher support for narrowing a rejected value differs between runners, and the `catch` form reads the same everywhere. +These prove the reason is on the SDK error, not that the caller sees it. Also test through your own boundary: +fake the API's `4xx` with a reason and assert the reason is in **your** response — a test that checks +only the status passes on code that logs the reason and drops it. + ## Test the result-style (`asApiResult`) variant `.asApiResult()` turns the error-status branch into a value, so there is nothing to catch — stub the @@ -198,7 +220,7 @@ it("reports the failure without throwing", async () => { expect(outcome.ok).toBe(false); if (!outcome.ok) { expect(outcome.status).toBe(400); - expect(outcome.error.kind).toBe("{arm}"); // outcome.error is the PAYLOAD, not the error object + expect(outcome.payload.kind).toBe("{arm}"); // the error's own members, not the error object } }); ``` @@ -220,25 +242,31 @@ it("sends the right request", async () => { await client.{resource}.{operation}({ {pathField}: "abc", {queryField}: 2, body: { name: "x" } }); - const { url, init } = stub.last(); - expect(init.method).toBe("POST"); + const { url, request, body } = stub.last(); + expect(request.method).toBe("POST"); expect(url.pathname).toBe("/{expected}/{route}/abc"); // path field substituted expect(url.searchParams.get("{wire-query-name}")).toBe("2"); // query lives on the URL - expect(new Headers(init.headers).get("content-type")).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ name: "x" }); // wire keys, wire dates + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(body!)).toEqual({ name: "x" }); // wire keys, wire dates }); ``` -- **`input` is a `URL`**, so `new URL(String(input))` is safe and gives you `pathname` and - `searchParams` for free rather than substring-matching a URL string. -- **Header names are lowercased.** The SDK passes a `Headers` instance and normalizes every layer — - the endpoint's, the auth scheme's and the client's defaults — before merging, which is what makes the - merge case-insensitive. Read them through `new Headers(init.headers)`; a plain `init.headers["Authorization"]` - is both the wrong type and the wrong case. -- **The body is already serialized by the time the fake sees it.** `init.body` is a **string** (or - `null`) for every body kind — JSON, form-urlencoded and text — and it uses **wire aliases**, because - that is what encoding produces. Assert on the parsed string; do not "fix" the aliases in the test. - The SDK never uses `FormData` or a stream. +- **The SDK hands the fake one `Request` and no `init`.** Its `url` is absolute, so + `new URL(request.url)` gives you `pathname` and `searchParams` for free rather than substring-matching + a URL string. A fake that reads `init` reads nothing. +- **Header names are lowercased.** The SDK normalizes every layer — the endpoint's, the auth scheme's + and the client's defaults — before merging, which is what makes the merge case-insensitive, and + `request.headers` is a `Headers`. Read it through `request.headers.get(...)`; a plain + `request.headers["Authorization"]` is both the wrong type and the wrong case. +- **The body is already serialized by the time the fake sees it.** The captured `body` is the request + body's **text** (or `undefined`), which is all a JSON, form-urlencoded or text body needs, and it + uses **wire aliases**, because that is what encoding produces. Assert on the parsed string; do not + "fix" the aliases in the test. Read a multipart or binary body back off the captured `request` + instead: a multipart one with `await request.formData()`, and a binary one with + `new Uint8Array(await request.arrayBuffer())` — its text is lossy. A multipart `content-type` + carries a generated boundary, so match it as a prefix, never with `toBe`. Compare a file part by its + contents, `await new Response(form.get("{field}")).text()`: one sent without a file name may come + back as a string or a `File`. - **A list-valued query or form parameter has a per-parameter wire style.** The `StyledParam` the generator emitted for it in `src/resources/{resource}.ts` carries a `style`: `plain` (the default — `name=a&name=b`), `indexed` (`name[0]=a`, percent-encoded in the raw URL as `name%5B0%5D=a`), @@ -260,37 +288,42 @@ it("sends the right request", async () => { Worth asserting once, somewhere, because they are silent when wrong: - **The value actually reached the wire.** Request models encode loosely too, so an extra or misspelled - optional member is carried into the body rather than rejected. Only an assertion on `init.body` - catches it. + optional member is carried into the body rather than rejected. Only an assertion on the captured + `body` catches it. - **A body that cannot encode never reaches your fake.** Encoding happens *before* the request is - dispatched, so the call rejects with `SchemaError` and `stub.calls` stays **empty**. Same for an - optional path parameter left `undefined` whose placeholder cannot be filled — that rejects with - `SdkError`, also before anything is sent. Assert `stub.calls` is empty in those tests. -- **`timeout` never rides the request.** It is a client-level option in **milliseconds** with no - per-request override, and it reaches the wire only as `init.signal`. A test asserting the client's - timeout on a captured request is asserting something that was never there. + dispatched, so the call rejects with `EncodeError` and `stub.calls` stays **empty**. Same for a path + parameter left `undefined` — its schema rejects it, also as an `EncodeError`, before anything is + sent. Assert `stub.calls` is empty in those tests. +- **`retry.timeout` never rides the request.** It is a per-attempt budget in **milliseconds**, set on + the client or on one call through `RequestOptions.retry`, and it reaches the wire only as the + `Request`'s `signal`. A test asserting a timeout on a captured request is asserting something that + was never there. -## Cover the failures that are not a `ResponseError` +## Cover the failures that are not an `ApiError` -Failures where no usable response was produced reject with a member of the `{Api}Error` set — the -SDK-branded alias of the abstract base — and **never** match a `ResponseError` check -(**typescript-error-handling**). Each `kind` is reachable from the fake, which makes these paths -genuinely testable rather than hypothetical: +Every other failure rejects with another member of the `{Api}Error` family — narrowed on its closed +`kind` — and **never** matches an `ApiError` check (**typescript-error-handling**). Each `kind` is +reachable from the fake, which makes these paths genuinely testable rather than hypothetical: | To produce | Make the fake `fetch` / the client | | --- | --- | | `kind: "connection"` | throw any error | -| `kind: "timeout"` | hang until aborted, with a tiny `timeout` on the client | -| `kind: "abort"` | hang until aborted, and abort the signal you passed in `RequestOptions` | -| `kind: "schema"` | return a body that violates the schema (wrong type, bad date, non-empty `204`) | +| `kind: "timeout"` | hang until aborted, with a tiny `retry.timeout` on the client | +| `kind: "decode"` | return a body that violates the schema (wrong type, bad date, a non-empty `200` where `undefined` is expected) | +| `kind: "encode"` | pass a request value its schema refuses — nothing reaches the fake | | `kind: "auth"` | answer the **token** request with an error status (see below) | -| `kind: "sdk"` | omit an optional path parameter, leaving its placeholder unfilled | + +A caller abort is **not** a `kind`: aborting the signal you passed in `RequestOptions` rejects with that +signal's own `reason`, outside the family. ```ts import { {Api}Error } from "{package-name}"; it("maps a transport failure", async () => { - const client = new {Api}Client({ fetch: async () => { throw new Error("ECONNREFUSED"); } }); + const client = new {Api}Client({ + fetch: async () => { throw new Error("ECONNREFUSED"); }, + retry: { maxRetries: 0 }, // a connection failure is retried otherwise + }); try { await client.{resource}.{operation}({ /* ... */ }); @@ -302,29 +335,34 @@ it("maps a transport failure", async () => { }); ``` -⚠ **A timeout or abort test needs a fake that honours `init.signal`.** The SDK aborts its own -controller and relies on `fetch` to reject with the abort reason; a fake that returns a -never-resolving promise and ignores the signal simply hangs the test until the runner kills it. +⚠ **A timeout or abort test needs a fake that honours the `Request`'s `signal`.** The SDK aborts the +signal it put on the `Request` and relies on `fetch` to settle; a fake that returns a never-resolving +promise and ignores the signal simply hangs the test until the runner kills it. What the fake rejects +with does not matter — the SDK reads the reason off its own signal — so a timeout is a `TimeoutError` +the retry loop retries, which is why the test below sets `maxRetries: 0`. ```ts -const hangingFetch: typeof globalThis.fetch = (_input, init) => +const hangingFetch: typeof globalThis.fetch = (input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + const { signal } = new Request(input, init); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); it("times out", async () => { - const client = new {Api}Client({ timeout: 10, fetch: hangingFetch }); + const client = new {Api}Client({ retry: { timeout: 10, maxRetries: 0 }, fetch: hangingFetch }); await expect(client.{resource}.{operation}({ /* ... */ })) .rejects.toMatchObject({ kind: "timeout" }); }); ``` -That is faster and more deterministic than fake timers, and it exercises the real abort path. For -`"abort"`, keep the default timeout and abort your own `AbortController` passed in `RequestOptions`. +That is faster and more deterministic than fake timers, and it exercises the real abort path. For a +caller abort, keep the default timeout, abort your own `AbortController` passed in `RequestOptions`, and +assert the call rejects with exactly the `reason` you passed — it is not an `{Api}Error`, and it is +never retried. **A decode failure bypasses *both* response modes.** A 2xx body that does not satisfy the schema -raises `SchemaError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your +rejects with `DecodeError`; `.asApiResult()` does **not** turn it into an `ok: false` result. Test that your boundary maps it: ```ts @@ -346,7 +384,7 @@ it("does not report a truncated success body as a success", async () => { ``` And the one people forget: **bad credentials**. A failed token fetch raises `AuthError` — `kind: -"auth"`, *not* a `ResponseError` — carrying the token endpoint's `ResponseError` on `.cause`. Return an +"auth"`, *not* an `ApiError` — carrying the token endpoint's `ApiError` on `.cause`. Return an RFC 6749 error body from the **token** request and assert your configuration error, not a rejection error: @@ -357,7 +395,7 @@ it("treats bad credentials as a config error", async () => { }); ``` -Being *refused* by the API is the disjoint case: that is a `ResponseError` with status 401/403 from the +Being *refused* by the API is the other case: that is an `ApiError` with status 401/403 from the operation itself. ## Fake an OAuth token endpoint @@ -377,8 +415,8 @@ const { client, stub } = clientReturning(tokenResponse(), jsonResponse(201, { id await client.{resource}.{operation}({ /* ... */ }); expect(stub.calls).toHaveLength(2); -const { init } = stub.last(); // index from the end, not calls[0] -expect(new Headers(init.headers).get("authorization")).toBe("Bearer T"); +const { request } = stub.last(); // index from the end, not calls[0] +expect(request.headers.get("authorization")).toBe("Bearer T"); ``` Or route on the path, when the order is what you are testing: @@ -393,18 +431,28 @@ Or bypass acquisition entirely with a stub token strategy — the generator's su (**typescript-authentication**) — so the fake only ever sees your operation: ```ts +const stubStrategy = { // keep it a variable: inline, tryRefreshToken is a type error on the other grants + getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }), + tryRefreshToken: async () => null, // the authorization-code grant requires it +}; + const client = new {Api}Client({ {scheme}: { clientId: "id", clientSecret: "secret" }, // still required — the scheme is off without it - {scheme}Strategy: { getToken: async () => ({ accessToken: "T", tokenType: "Bearer" }) }, + {scheme}Strategy: stubStrategy, fetch: stub.fetch, }); ``` +That credential fits the client-credentials grant. Match `{scheme}`'s type in `src/client-options.ts`: +`OAuth2PasswordCredentials` needs `username` and `password`, and `OAuth2AuthorizationCodeCredentials` +needs `redirectUri` and `promptForAuthorizationCode` (e.g. `async () => "code"`). + Facts that make these tests behave: - **Forgetting the token request is the most common way a first test fails confusingly.** A - single-response fake hands your operation's body to the *token* decoder, and you get a `SchemaError` - about a missing `access_token` rather than anything mentioning auth. + single-response fake hands your operation's body to the *token* decoder, and you get an `AuthError` + whose `cause` is a `DecodeError` about a missing `access_token` — which reads like wrong credentials + when the fake simply answered the wrong request. - The token **response** is wire-shaped (`access_token`, `token_type`, `expires_in`); the token **request** is a `POST` with a form-urlencoded string body, and by default the client id and secret ride in a Basic `Authorization` header rather than in that body. @@ -413,8 +461,9 @@ Facts that make these tests behave: - The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Build a fresh client per test to reset it. A missing or non-positive `expires_in` means the cached token never expires. -- The client `timeout` bounds the token request **and** the operation together, so a tiny timeout in an - OAuth test can fire on the token leg. +- The token request and the operation each run on their own `retry.timeout`, so a tiny timeout in an + OAuth test can fire on the token request — which ends the call with a `TimeoutError` for the token + endpoint, not retried unless `httpMethodsToRetry` names `POST`. ## The alternative: intercepting global `fetch` @@ -443,9 +492,9 @@ after the client is built has no effect. so `{package-name}/models/…` does not resolve at all. Everything you need — the client, the options, every model type and its `{model}Schema`, the error classes, `ServerEnvironment` — is re-exported from the package root. Reading a file under `src/` is for *learning* the shape, never for importing. -- **Never assert on an error's `message`.** A `ResponseError`'s is only the status and status text - (with `"HTTP error"` standing in for the empty `statusText` a hand-built `Response` gives you). It is - not a contract. Assert `err.status` and the narrowed `err.payload`. +- **Never assert on an error's `message`.** An `ApiError`'s is the call and its status — + `POST https://… failed with 400` — which moves whenever the base URL or the route does. It is not a + contract. Assert `err.status` and the narrowed `err.payload`. - **Never derive test data from the schema's own definition** — reading `_keysMap` to generate a payload means the fixture cannot detect a wrong payload. Construct models explicitly and `{model}Schema.encode` them. @@ -481,7 +530,7 @@ describe.skipIf(!hasCredentials)("integration", () => { const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{SandboxEnvironment}, // state it, never rely on the default {scheme}: { /* from env */ }, - timeout: 15_000, + retry: { timeout: 15_000 }, // per attempt; retries stay on }); const result = await client.{resource}.{operation}({ /* ... */ }); expect(result.{member}).toBeDefined(); @@ -494,29 +543,35 @@ unless you mean to. ## Notes -- **The response's `content-type` is never read.** The decoder is fixed by the operation, so a stubbed +- **The response's `content-type` picks no decoder.** The decoder is fixed by the operation, so a stubbed `Response` needs no `content-type` header; setting one is harmless documentation. What matters is - that the body parses and satisfies the schema. + that the body parses and satisfies the schema. A binary response is the exception: its result + reports `content-type` and the `content-disposition` file name, so stub them when your code reads them. - **Matcher precedence:** an exact-status arm wins over a range or `"5XX"` wildcard arm. A test that stubs a status covered by both is asserting the exact one. -- **A `401` invalidates the cached OAuth token but does not retry the request.** A test that stubs a +- **A `401` invalidates the cached OAuth token and is not retried by default.** A test that stubs a `401` sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `tokenResponse()` for it. -- **The SDK performs no retries and logs nothing**, so a stubbed `429`/`503` is seen exactly once and - your queue needs exactly one response for it. If your code adds its own retrying `fetch` (see - **typescript-configuration-resilience**), that is *your* code under test — test the wrapper - **directly**, since it is a plain function taking a `fetch` and returning one: +- **Retries are on by default, and silent unless logging is on.** On a client built without + `retry: { maxRetries: 0 }`, a stubbed `408`, `429`, `500`, `502`, `503` or `504` — or a thrown + error — on a `GET`, `HEAD`, + `PUT` or `OPTIONS` call is fetched up to four times, across about 7 s of backoff. A queue that runs out + throws, and that throw is itself a connection failure the loop retries. When the test *is* about + your retry policy, queue one response per attempt, pass `delay: 0` so it runs at once, and count + requests: ```ts - const statuses = [503, 200]; - const inner: typeof globalThis.fetch = async () => jsonResponse(statuses.shift()!, { id: "1" }); - const client = new {Api}Client({ fetch: retryingFetch(3, inner) }); + const stub = stubFetch(queue(jsonResponse(503, {}), jsonResponse(503, {}), jsonResponse(200, { id: "1" }))); + const client = new {Api}Client({ fetch: stub.fetch, retry: { delay: 0 } }); - await client.{resource}.{operation}({ /* ... */ }); - expect(statuses).toHaveLength(0); // both responses consumed + await client.{resource}.{operation}({ /* ... */ }); // an operation whose method is in the gate + expect(stub.calls).toHaveLength(3); ``` - Cover the case that is easiest to get wrong: an aborted request must not keep retrying. + A stubbed `Retry-After` header overrides `delay`, so leave it off unless the test is about it; a test + about the backoff itself wants `maxJitter: 0` and fake timers. Cover the cases that are easiest to get + wrong: a write outside the gate is sent **once**, a widened write re-sends the **same** + `Idempotency-Key` on every attempt, and an aborted request stops retrying. - **A test environment with no global `fetch` fails in the constructor**, not on the first call — the - client throws `SdkError` when it can resolve no implementation. Passing a fake avoids this entirely. + client throws `ConfigurationError` when it can resolve no implementation. Passing a fake avoids this entirely. - **`instanceof` is dialect-scoped.** If the test setup loads the SDK through `require` and the code under test through `import` (or vice versa), `instanceof` is `false` across that boundary. Narrow on `err.kind` / `err.payload.kind` there, or fix the config to use one dialect. diff --git a/plugins/alpaca/.claude-plugin/plugin.json b/plugins/alpaca/.claude-plugin/plugin.json index 74cef4a73..001e5427d 100644 --- a/plugins/alpaca/.claude-plugin/plugin.json +++ b/plugins/alpaca/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "alpaca", "displayName": "Alpaca", - "version": "0.2.2", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Trader API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/alpaca/.cursor-plugin/plugin.json b/plugins/alpaca/.cursor-plugin/plugin.json index 74cef4a73..001e5427d 100644 --- a/plugins/alpaca/.cursor-plugin/plugin.json +++ b/plugins/alpaca/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "alpaca", "displayName": "Alpaca", - "version": "0.2.2", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Trader API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/alpaca/plugin.json b/plugins/alpaca/plugin.json index 74cef4a73..001e5427d 100644 --- a/plugins/alpaca/plugin.json +++ b/plugins/alpaca/plugin.json @@ -1,7 +1,7 @@ { "name": "alpaca", "displayName": "Alpaca", - "version": "0.2.2", + "version": "0.3.0", "description": "Integrate and consume the APIMatic-generated Trader API SDK — bundled skills for C#/.NET, python, TypeScript.", "author": { "name": "APIMatic", diff --git a/plugins/alpaca/skills/dotnet/dotnet-authentication/SKILL.md b/plugins/alpaca/skills/dotnet/dotnet-authentication/SKILL.md index aea76e8da..7e406c9f4 100644 --- a/plugins/alpaca/skills/dotnet/dotnet-authentication/SKILL.md +++ b/plugins/alpaca/skills/dotnet/dotnet-authentication/SKILL.md @@ -1,245 +1,81 @@ --- -name: dotnet-authentication -description: Authentication for an APIMatic-generated .NET SDK in C# — supplying credentials, the auth scheme and manager shape, per-environment configuration, and rotating or refreshing credentials. Load before wiring credentials or an auth scheme into the client, or when a call fails with 401/403. +name: "dotnet-authentication" +description: "Authentication for the Alpaca .NET SDK in C# — this API authenticates with an API key in a header, and this skill gives the credential each scheme takes, the AlpacaClientOptions property to set it on, and the startup check that keeps a missing secret from surfacing as a 401. Load before wiring credentials into the client, or when a call comes back 401/403." --- -# Authenticating an APIMatic .NET SDK client +# Authenticating the Alpaca .NET SDK client -How you authenticate depends on the security scheme(s) the API uses. APIMatic surfaces each scheme as a -**nullable credentials property on the options class**; set the one(s) your API uses, then construct the -client (see `dotnet-client-initialization`). +This API authenticates with an API key in a header. APIMatic surfaces each scheme as a **nullable credentials property** on `AlpacaClientOptions`; set the ones below, then construct the client (see `dotnet-client-initialization`). -> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{RootNamespace}`, -> `{Api}ClientOptions`, `{BasicAuthProperty}`) — replace it with the concrete identifier from the source. - -To see which schemes a specific SDK accepts, read the **credentials properties on its `{Api}ClientOptions` -class** — those are the source of truth (take them from the contract sheet, grounded from the -SDK map/source, not a decompiled or reflected view of the installed package). The `{RootNamespace}.Core.Authentication` folder ships *every* -scheme class as shared runtime code regardless of what the API accepts, so rely on the options class rather -than that folder. (An SDK whose API uses only Basic, for instance, exposes a single -`options.{BasicAuthProperty}` of type `BasicAuthCredentials`.) - -The credential classes below live under `{RootNamespace}.Core.Authentication.*` and are the **same across -all APIMatic .NET SDKs**; only the **options property names** are generated per-API (hence the -`{...Property}` placeholders). - -## Basic auth - -```csharp -using {RootNamespace}.Core.Authentication.Basic; - -options.{BasicAuthProperty} = new BasicAuthCredentials -{ - Username = "...", - Password = "..." -}; -``` - -Sends `Authorization: Basic base64(username:password)`. - -## Bearer token - -Set the configured token property on the options class to your access-token string: - -```csharp -options.{BearerAuthProperty} = "ACCESS_TOKEN"; -``` - -Sends `Authorization: Bearer ACCESS_TOKEN`. - -## API key (header, query, or cookie) - -The key is sent as a header, query parameter, or cookie — its placement and name are fixed by the generated -scheme. Set the configured key property to your key string: - -```csharp -options.{ApiKeyProperty} = "API_KEY"; -``` +| Scheme | Property on `AlpacaClientOptions` | Credential | +| --- | --- | --- | +| API key — `APCA-API-KEY-ID` header | `ApiKey` | `string` | +| API key — `APCA-API-SECRET-KEY` header | `ApiSecret` | `string` | -## OAuth 2.0 — client credentials (machine-to-machine) +**That table is the entire authentication surface of this SDK.** `Alpaca.Core.Authentication` ships *every* scheme class APIMatic supports as shared runtime code, including kinds this API does not use, so read what is configurable off the properties above rather than off that folder. -```csharp -using {RootNamespace}.Core.Authentication.OAuth2.ClientCredentials; - -options.{OAuthProperty} = new OAuth2ClientCredentials -{ - ClientId = "...", - ClientSecret = "...", - Scope = "..." // optional -}; -``` +## API key (header) — `ApiKey` -The SDK fetches and caches the token, acquiring a fresh one when it expires; on a `401` it invalidates the -cached token and re-acquires. - -## OAuth 2.0 — authorization code (3-legged, with PKCE) +The key travels in the `APCA-API-KEY-ID` request header. The header name is fixed by the generated scheme — you supply only the value: ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.AuthorizationCode; - -options.{OAuthProperty} = new OAuth2AuthorizationCodeCredentials -{ - ClientId = "...", - ClientSecret = "...", // see the note below — often required even with PKCE - RedirectUri = "https://app.example.com/callback", - Scope = "...", // optional - State = "...", // optional CSRF token - Pkce = PkceMethod.S256, // default; RFC 7636 - PromptForAuthorizationCode = async (authorizationUrl, ct) => - { - // Open/redirect the browser to authorizationUrl, then return the - // authorization code your redirect endpoint received. - return await GetCodeFromUserAsync(authorizationUrl, ct); - } -}; +options.ApiKey = "API_KEY"; ``` -The SDK exchanges the code for a token and refreshes it when it expires; if the refresh fails, it invokes -`PromptForAuthorizationCode` again to re-authorize. - -⚠ **`ClientSecret` is optional only when PKCE is enabled *and* the token request is form-body style.** -Two independent guards, failing at different moments: +## API key (header) — `ApiSecret` -| PKCE | token request built | no secret ⇒ | -| --- | --- | --- | -| disabled (`Pkce = null`) | either factory | `InvalidOperationException("ClientSecret is required when PKCE is disabled.")` — **before** the prompt runs | -| enabled (default `S256`) | `ForBasicAuthRequest` | `InvalidOperationException("Basic auth requires a client secret. For public clients, enable PKCE by …")` — **after** `PromptForAuthorizationCode` has already run | -| enabled | `ForFormBodyRequest` | fine — this is the public-client case | - -The Basic form is the common wiring, and its failure is the expensive one: the user completes a full -browser round-trip before the exchange throws. Check which factory your generated `AuthSchemes` uses before -treating the secret as optional. - -## OAuth 2.0 — resource owner password +The key travels in the `APCA-API-SECRET-KEY` request header. The header name is fixed by the generated scheme — you supply only the value: ```csharp -using {RootNamespace}.Core.Authentication.OAuth2.Password; - -options.{OAuthProperty} = new OAuth2PasswordCredentials -{ - ClientId = "...", - ClientSecret = "...", // optional - Username = "...", - Password = "...", - Scope = "..." // optional -}; +options.ApiSecret = "API_KEY"; ``` -## Token caching & refresh (all OAuth2 grants) - -- Tokens are cached in-memory, per client instance, and reused until **30s** before expiry. -- **Only the authorization-code grant refreshes.** It is the one grant wired to - `IOAuth2RefreshableTokenStrategy` / `OAuth2RefreshableScheme`. Client-credentials and - resource-owner-password re-run the whole grant when the token expires — and a `refresh_token` in a - password-grant response is discarded, because the non-refreshable `OAuthToken` has no binding for it. Do - not infer refresh behaviour from what the provider returns. -- On `401`, the cached token is invalidated and re-acquired on the next call — the failing request is - **not** retried. For the authorization-code grant, invalidation drops the refresh token along with the - access token, so "re-acquired" means the *full* grant runs again and `PromptForAuthorizationCode` fires: - a `401` there is an interactive re-authorization, not a silent refresh. Plan for that in a - non-interactive host. - -⚠ **If the token response omits `expires_in`, the token never expires as far as the SDK is concerned.** -`expires_in` is RECOMMENDED but not required by RFC 6749, and the SDK's expiry check short-circuits to -"not expired" when it is absent — so the first token is cached for the life of the client and the only -thing that ever replaces it is a `401`. That is usually fine and occasionally not: a token revoked -server-side keeps being sent until a request fails with it. If your provider omits `expires_in` and you -need proactive rotation, supply your own token strategy (below) rather than trying to bound the cache. - -- **Invalidation is a hint, not a barrier.** `Invalidate()` clears the cache without taking the fetch lock, - so a token fetch already in flight can complete and re-populate it. That is deliberate — the refreshed - token post-dates the invalidation — but it means "invalidate then immediately read" is not a guarantee of - a fresh token. - -### Supplying your own token source - -The token strategy is a public extension point: the options class exposes a -`{OAuthProperty}TokenStrategy` alongside the credentials, typed -`IOAuth2TokenStrategy<{CredentialsType}>` (or `IOAuth2RefreshableTokenStrategy<…>` for refreshable grants), -and the generated client falls back to the built-in strategy only when you leave it null. +## Combining the schemes -```csharp -options.{OAuthProperty}TokenStrategy = new MyTokenStrategy(); // Task GetToken(creds, ct) -``` +This SDK generates more than one credentials property, and its operations compose them. You configure the composition by setting the relevant properties on `AlpacaClientOptions` — the generated client wires the rest. -Reach for it when the token must come from somewhere other than the SDK's own call to the token endpoint — -a shared cache across processes, a secrets broker, a sidecar that already holds a valid token, or a test -double. The per-client in-memory cache above still wraps whatever you return. - -## Combined / multiple schemes - -When an operation (or the whole API) requires more than one scheme, APIMatic composes them: - -- **AND** — all schemes are applied to every request (`AuthSchemeAll`). -- **OR** — schemes with **no credentials configured are skipped, not tried**; the first configured scheme - that succeeds wins, and `AuthSchemeException` is thrown only if every *configured* scheme fails - (`AuthSchemeAny`). - -You configure this by setting the relevant credentials properties on the options class; the generated -client wires the AND/OR composition for you. - -⚠ **Configure nothing and the request goes out unauthenticated — no exception.** This is the behaviour of a -single unconfigured scheme too: an unset credentials property yields a no-op scheme, not an error, so the -call reaches the provider with no `Authorization` header and comes back `401`. The SDK will never tell you -that you forgot to supply a credential; only the provider will, one round-trip later and one layer away -from the cause. That is why the startup check in *Missing credentials must stop the app from starting* -(below) is not optional politeness. - -## No auth - -Some endpoints/APIs need no credentials (`NoneAuthScheme`) — leave the credentials properties unset. +**AND (`AuthSchemeAll`) — every scheme in the group is applied to the request.** Leave one of them unconfigured and the request still goes out, just without that credential; the provider decides what to make of a half-authenticated call. ## ⚠ Missing credentials must stop the app from starting -Every section above covers supplying a credential. This one covers its **absence** — the case a -configuration-driven app hits in the real world, and the one most integrations get wrong. +⚠ **Configure nothing and the request goes out unauthenticated — no exception.** An unset credentials property on `AlpacaClientOptions` yields a no-op scheme, not an error, so the call reaches the provider with no credential and comes back `401`. The SDK will never tell you that you forgot to supply one; only the provider will, one round-trip later and one layer away from the cause. -**A required credential that is not configured is a deployment fault, not a request fault.** If the app -boots with a blank secret, the failure surfaces later as a `401` from the provider on whichever unlucky -request arrives first: an operator sees a provider outage, retry logic hammers a call that can never -succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. +**A required credential that is not configured is a deployment fault, not a request fault.** If the app boots with a blank secret, an operator sees a provider outage, retry logic hammers a call that can never succeed, and the actual cause — an unset environment variable — is two layers away from the symptom. -**Validate at startup and refuse to boot.** Bind the credentials into an options object and make the host -check it before the app serves anything: +**Validate at startup and refuse to boot.** Bind the credentials into your own options type and make the host check it before the app serves anything: ```csharp builder.Services - .AddOptions<{Api}Settings>() - .Bind(builder.Configuration.GetSection("{Api}")) + .AddOptions() + .Bind(builder.Configuration.GetSection("AlpacaClient")) .ValidateDataAnnotations() // [Required] on each credential property .ValidateOnStart(); // throws during startup, not on first request ``` -`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on -first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a -console app or anywhere the options pipeline is overkill, an explicit guard is equally acceptable as long -as it runs **before** the app is ready: +`ValidateOnStart()` is the load-bearing call — without it, `IOptions` validation is lazy and fires on first resolution, which is a request, which is exactly the late failure you are trying to avoid. For a console app, an explicit guard is equally acceptable as long as it runs **before** the app is ready: ```csharp -if (string.IsNullOrWhiteSpace(settings.{Credential})) +if (string.IsNullOrWhiteSpace(settings.ApiKey)) throw new InvalidOperationException( - "{Api}:{Credential} is not configured. Set it via environment variable, user-secrets, " + - "or your secret store before starting the app."); + "AlpacaClient:ApiKey is not configured. Set it via environment variable, " + + "user-secrets, or your secret store before starting the app."); ``` Three rules for the message it fails with: -- **Name the missing config key**, so the operator knows what to set. `"{Api}:{Credential} is not - configured"` — not `"authentication failed"`. -- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: - AC1234…" line is a secret in a log. -- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides - the fault and pushes it to the first caller. +- **Name the missing config key**, so the operator knows what to set — `"AlpacaClient:ApiKey is not configured"`, not `"authentication failed"`. +- **Never echo the value**, present or absent — no length, no prefix, no masked form. A "configured: AC1234…" line is a secret in a log. +- **Do not fall back to a default, a placeholder, or an unauthenticated client.** Booting degraded hides the fault and pushes it to the first caller. -Check every credential the scheme requires. Basic auth needs both halves — a username with an empty -password is misconfigured, not partially configured. +Check every value the schemes actually need. For this SDK that is: + +| Value to validate | Feeds `AlpacaClientOptions` property | +| --- | --- | +| `ApiKey` | `ApiKey` | +| `ApiSecret` | `ApiSecret` | ## Notes -- A given SDK only exposes the credentials properties for the schemes its API uses; those names are - generated per-API (hence the `{...Property}` placeholders above). -- Set credentials **before** constructing the client, or inside the `Add{Api}Client(options => ...)` - callback when registering via DI. -- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any - other `IConfiguration` source) instead of hardcoding, either inside the `Add{Api}Client(options => ...)` - callback for the host or via a `ConfigurationBuilder()...Build()` chain for a console app. +- Set credentials **before** constructing the client, or inside the `AddAlpacaClient(options => ...)` callback when registering via DI. `AlpacaClientOptions` is read at construction; mutating it afterwards does not re-wire an existing client. +- Keep secrets out of source — load them from configuration (environment variables, a secret store, or any other `IConfiguration` source) instead of hardcoding, either inside the `AddAlpacaClient(options => ...)` callback for a host or via a `ConfigurationBuilder()...Build()` chain for a console app. + diff --git a/plugins/alpaca/skills/dotnet/dotnet-calling-endpoints/SKILL.md b/plugins/alpaca/skills/dotnet/dotnet-calling-endpoints/SKILL.md index 4ea8723ec..fb0db7125 100644 --- a/plugins/alpaca/skills/dotnet/dotnet-calling-endpoints/SKILL.md +++ b/plugins/alpaca/skills/dotnet/dotnet-calling-endpoints/SKILL.md @@ -16,76 +16,98 @@ names follow no fixed verb/resource pattern, so take the real name from the shee ## Method signature convention -Every endpoint method is `async` (returns a `Task`) and lays its parameters out in a fixed order: +Every endpoint method is `async` (returns a `Task`) and takes at most three parameters, in a fixed order: ```csharp public Task<{ReturnType}> {Operation}( - {non-defaulted params}, // no C# default value — listed first - {defaulted params} = {default}, // have a C# default (e.g. = null, = 1d) — may be skipped - RequestOptions? requestOptions = null, // on EVERY operation — see below - CancellationToken ct = default); // always last + {Operation}Request request, // every input — absent when the operation takes none + RequestOptions? requestOptions = null, // on EVERY operation — see below + CancellationToken cancellationToken = default); // always last ``` -- **Parameter order is fixed:** parameters **without a default value come first**, then parameters **with a - default value**, then `RequestOptions? requestOptions = null`, then `CancellationToken ct = default` last - (C# requires defaulted parameters to follow non-defaulted ones). -- **`requestOptions` is on every generated operation**, between the last defaulted parameter and `ct`. It +- **Every input travels on one request record.** Path, query and header parameters, the body, and the + fields of a form all become properties of `{Operation}Request`. There are no flat arguments, so there is + no parameter order to get wrong: you build the record with an object initializer and set members by name. +- **`request` has no default.** An operation that takes input always needs a record, even when every member + is optional — pass `new {Operation}Request()`. An operation that takes **no** input has no `request` + parameter at all. +- **The record lives in `Requests/`**, in the namespace `{RootNamespace}.Requests` for an operation on the + client and `{RootNamespace}.Requests.{ApiGroup}` for one on a controller. An operation with several + tags is filed under the first tag it declares, so its namespace can name a different controller from + the one you call it through. Its name is normally `{Operation}Request`; where a model already owns that + name it is `{Operation}OperationRequest`. Take the real name and file from the operation's + **Type sources** table — never assume it. +- **`requestOptions` is on every generated operation**, between `request` and `cancellationToken`. It is a `{RootNamespace}.Core.RequestOptions` with two properties — `LogLevel?`, a per-call logging override, and `Hooks`, a per-call `SdkHook` list appended after the client-wide `options.Hooks` (both in **dotnet-configuration-resilience**). You will rarely set it, but you must **count** it: a - positional call written from a signature that leaves it out puts your `CancellationToken` where a - `RequestOptions?` is expected, and the call fails to compile with - `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' to - '{RootNamespace}.Core.RequestOptions?'`. Pass `ct:` by name and the problem cannot arise. -- **An optional parameter may still have no C# default.** Many nullable query params are generated without a - `= null` default (e.g. `string? startDate`), so they sit in the leading group and must be passed - explicitly (as `null`) in a positional call — which is why named arguments matter (see below). -- **The contract sheet is the source of truth for the signature.** Whether a parameter is nullable, - required, or defaulted — and whether the operation takes a body — varies per operation. Path params are - typically non-nullable primitives listed first; query and body params may be required or optional. Take - each operation's exact signature from the contract sheet (grounded from the SDK map/source), not from memory. + positional call that skips it puts your `CancellationToken` where a `RequestOptions?` is expected, and the + call fails to compile with `CS1503: Argument N: cannot convert from 'System.Threading.CancellationToken' + to '{RootNamespace}.Core.RequestOptions?'`. Pass `cancellationToken:` by name and the problem cannot arise. +- **The contract sheet is the source of truth for the record.** Which members are `required`, which are + nullable, which carry a default — and whether there is a `Body` — varies per operation. Take each + operation's record from the contract sheet (grounded from the SDK map/source), not from memory. - **Return type** varies by operation — see [Reading the response](#making-the-call-and-reading-the-response). -- Methods are **async-only** (no sync overloads) and **throw `SdkException`** on API errors — see +- Methods are **async-only** (no sync overloads) and **throw `ApiException`** on API errors — one leaf + of the SDK's `SdkException` family, which also covers connection failures and timeouts — see `dotnet-error-handling`. -## Use named arguments for list/search endpoints +## Building the request record -List/search operations can have **many** optional parameters in a **fixed positional order**, and many of -the leading nullable ones have **no default value** — so you cannot skip them positionally. +The record's source file is the parameter list. Each property is one of four shapes, and the shape tells you +what you owe it: -Call these methods with named arguments. A positional call reconstructed from memory or an incomplete view -of the signature mis-binds arguments (wrong order, or a missing non-defaulted arg before the first defaulted -one), so it either fails to compile or sends the wrong request; named arguments are order-independent and -avoid this. When copying: - -- Copy parameter names and order from the C# method signature (`public Task<...> {Operation}(...)`), not - from the internal `new Param("...", ...)` builder list inside the method body — that list is ordered - differently and uses snake_case wire names. -- Copy each name verbatim from the signature; they are easy to misremember (singular vs plural, etc.). +| Property | Meaning | You | +|---|---|---| +| `public required T X { get; init; }` | required, no spec default | **must** set it — the compiler enforces this | +| `public T X { get; init; } = value;` | the spec declares a default | set it only to override; leaving it sends the default | +| `public T? X { get; init; }` | optional, no default | leave it unset to omit it from the request | +| `public T X { get; } = value;` | the spec fixes the value (`const`) | cannot set it — it is sent as declared | ```csharp -// Named args: order-independent; omitted optionals fall through to null / their defaults. var response = await client.{ApiGroup}.{Operation}( - status: {EnumType}.SomeConstant, - someFilterId: 12345d, - someFlag: true, - page: 1, // int? or double? per API — take the type from the contract sheet - perPage: 100, - ct: ct); + new {Operation}Request + { + Status = {EnumType}.SomeConstant, + SomeFilterId = 12345d, + Page = 1, // int or double per API — take the type from the record + PerPage = 100 + }, + cancellationToken: ct); ``` +- **Property names are PascalCase C# names, not wire names.** `per_page` on the wire is `PerPage` on the + record. The wire keys appear only inside the generated method body, as `new Param("per_page", request.PerPage)` + — read names off the record, never off that list. +- **The record is never serialized**, so it carries no `[JsonPropertyName]`. Its members may carry constraint + attributes (`[Minimum(0)]`, `[MaxLength(256)]`); they document the API's limits and nothing in the SDK + enforces them before the call. +- **A JSON, plain-text or binary body is always the member `Body`** (`Body_` in the rare API that also has a parameter + literally named `body`). A `application/x-www-form-urlencoded` or multipart form has **no** `Body`: each + form field is its own property on the record, and the generator assembles the form itself. + +## Files + +A file is a `BinaryContent`, built with an object initializer that sets all three: a `Stream`, a `FileName` +(may be `null`) and a `ContentType` +(a `MediaTypeHeaderValue`). A `Stream` or `byte[]` also converts to one, typed `application/octet-stream` and +named only for a `FileStream`; the SDK does not apply the spec's media type, so set `ContentType` whenever the spec +declares one, and `FileName` when the API needs a name. The SDK never disposes or rewinds your stream, and never retries a call +that sends a file. + ## Building request models -**Only a JSON-bodied operation has a `body` parameter.** Where the API declares a -`application/x-www-form-urlencoded` request, the generator emits the fields as *individual method -parameters* and assembles the form itself — there is no request record to construct and no `body` argument -to pass. In an API built that way this is the majority shape, not an exception. The contract sheet says -which you are looking at; the signature settles it. +**A form-bodied operation has no `Body` member.** Where the API declares a +`application/x-www-form-urlencoded` or multipart request, the generator emits the fields as *individual +members of the request record* and assembles the form itself — there is no body model to construct and no +`Body` to set. +In an API built that way this is the majority shape, not an exception. The contract sheet says +which you are looking at; the record settles it. For the JSON case: request bodies are immutable `record`s built with object-initializer syntax (no builders). `required` members must be set; optional ones are nullable and are omitted from the JSON when -left null. The request type is the type of the operation's `body` parameter — take its exact name from the -contract sheet (grounded from the SDK map/source): +left null. The body model is the type of the request record's `Body` member — a different type from the +request record itself — so take its exact name from the contract sheet (grounded from the SDK map/source): ```csharp var body = new {RequestType} @@ -112,14 +134,15 @@ var body = new {RequestType} ## Enums -Enums are type-safe string- **or int-**enums (`StringEnum` / `IntEnum`), not C# enums — use the -static constants, or `FromValue(...)` for a value not known at compile time — where the enum exposes it; -some do not, and `dotnet-models` says which to check. See **dotnet-models** for -read-back semantics (they convert to their underlying value; `==` compares by value; guard unknowns). +Enums are type-safe string- **or int-**enums (`OpenStringEnum` / `OpenIntEnum`), not C# enums — use +the static constants. There is no `FromValue`: a value the spec does not declare cannot be constructed, and +a raw value resolves through `{EnumType}.TryGetKnownValue(value, out var known)`. See **dotnet-models** for +read-back semantics (implicit conversion to the underlying value, `==` / `Is` against raw values, and the +generated `Match`). ```csharp SomeProp = {EnumType}.SomeConstant; -SomeProp = {EnumType}.FromValue("server_provided_value"); +SomeProp = {EnumType}.TryGetKnownValue(configured, out var known) ? known : {EnumType}.SomeConstant; ``` ## Union types, collections, and dates @@ -132,11 +155,13 @@ how to construct and read it. ## Making the call and reading the response ```csharp -var response = await client.{ApiGroup}.{Operation}(pathArg, queryArg: null, body: body, ct: ct); +var response = await client.{ApiGroup}.{Operation}( + new {Operation}Request { {PathMember} = pathValue, Body = body }, + cancellationToken: ct); ``` > **Wrap the call in error handling — a non-2xx response *throws*, it is not signalled by the return value.** -> The bare `await` above shows only the happy path; on an API error the call throws `SdkException`. +> The bare `await` above shows only the happy path; on an API error the call throws `ApiException`. > Before writing a real call, **load `dotnet-error-handling`** for how to wrap it — the `try/catch` shape and > which `TError` to catch per operation — or use the non-throwing `{Operation}Result` variant (below). @@ -151,13 +176,18 @@ contract sheet's response-envelope column names the return type and the inner fi ``` - **The resource directly** — `Task<{Resource}>`: use it as-is, nothing to unwrap. ```csharp - var resource = await client.{ApiGroup}.{Operation}(...); + var resource = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); ``` - **An array** — `Task>`: iterate it, unwrapping each item too if the items are themselves nesting objects. - **An object that nests an array** — a record whose single member is an `IReadOnlyList<...>`. Read that member first, then iterate. - **Nothing** — non-generic `Task`: no body; just `await` it. +- **A file** — `Task`: the call returns when the headers arrive, and the body is still on the + open connection. Dispose it to free the connection, even if you never read it. `FileName` comes from the + server's `Content-Disposition` and may be null, so sanitize it before you use it as a path. No SDK timeout + bounds the read, and a drop while you read throws a raw `IOException` from your read, not + `SdkConnectionException`. Endpoints in the same family can differ — one nests the resource, another returns it directly — so let each method's return type guide how you read it. @@ -169,27 +199,32 @@ exist. See **dotnet-error-handling**. ## Cancellation -Every operation takes a `CancellationToken` as its last argument, passed as `ct:`, with -`RequestOptions? requestOptions = null` immediately before it — so `ct` is last, never second-to-last. To bound an individual call with a +Every operation takes a `CancellationToken` as its last argument, passed as `cancellationToken:`, with +`RequestOptions? requestOptions = null` immediately before it — so the token is last, never second-to-last. To bound an individual call with a timeout, use the per-request cancellation pattern in **dotnet-configuration-resilience** (it owns timeouts). ## Worked example — a list/GET call ```csharp -// Signature (illustrative): -// Task> {Operation}( -// {EnumType}? filter, string? startDate, string? q, -// {Num}? page = {p}, {Num}? perPage = {n}, // {Num} is int? or double? PER API — check the sheet -// RequestOptions? requestOptions = null, CancellationToken ct = default); +// Record (illustrative) — Requests/{ApiGroup}/{Operation}Request.cs: +// public sealed record {Operation}Request +// { +// public {EnumType}? Filter { get; init; } +// public string? StartDate { get; init; } +// public string? Q { get; init; } +// public {Num} Page { get; init; } = {p}; // {Num} is int or double PER API — check the record +// public {Num} PerPage { get; init; } = {n}; +// } var results = await client.{ApiGroup}.{Operation}( - filter: {EnumType}.SomeConstant, - startDate: null, - q: "search text", - page: 1, // literal must match the parameter's type — a `1d` against an `int?` is CS1503 - perPage: 20, - ct: ct); + new {Operation}Request + { + Filter = {EnumType}.SomeConstant, + Q = "search text", + PerPage = 20 // literal must match the member's type — a `20d` against an `int` is CS0266 + }, // StartDate is left unset and omitted; Page keeps the spec default + cancellationToken: ct); foreach (var item in results) { diff --git a/plugins/alpaca/skills/dotnet/dotnet-client-initialization/SKILL.md b/plugins/alpaca/skills/dotnet/dotnet-client-initialization/SKILL.md index f4f08dba1..948c68ebf 100644 --- a/plugins/alpaca/skills/dotnet/dotnet-client-initialization/SKILL.md +++ b/plugins/alpaca/skills/dotnet/dotnet-client-initialization/SKILL.md @@ -24,7 +24,7 @@ public {Api}Client(HttpClient httpClient, {Api}ClientOptions options) Operations are exposed on the client. Most are grouped under **controller properties** (one per API resource group) and called `client.{ApiGroup}.{Operation}(...)` — for example, a `Widgets` controller's -`ListWidgets` operation is `client.Widgets.ListWidgets(...)`. An operation that belongs to no group sits +`ListWidgets` operation is `client.Widgets.ListWidgets(new ListWidgetsRequest { ... })`. An operation that belongs to no group sits **directly on the client**, called `client.{Operation}(...)`. The available controller properties (and any direct operations) come from the contract sheet (grounded from the SDK map/source), not a decompiled or reflected view of the installed package. See `dotnet-calling-endpoints`. diff --git a/plugins/alpaca/skills/dotnet/dotnet-configuration-resilience/SKILL.md b/plugins/alpaca/skills/dotnet/dotnet-configuration-resilience/SKILL.md index eb1feafa6..8d1c807a0 100644 --- a/plugins/alpaca/skills/dotnet/dotnet-configuration-resilience/SKILL.md +++ b/plugins/alpaca/skills/dotnet/dotnet-configuration-resilience/SKILL.md @@ -11,9 +11,10 @@ description: Client configuration and resilience for an APIMatic-generated .NET > API definition, not of this skill: take them from the contract sheet or the map, and **apply > only the guidance that matches**. -Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the SSE -exceptions sit under `.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names -its own namespace. All of them are generic across APIMatic .NET SDKs built by this generator. +Most types below live under `{RootNamespace}.Core.Configuration` and `{RootNamespace}.Servers`; the +exception family (`SdkException` and its leaves — see **dotnet-error-handling**) sits under +`.Core.Exceptions` and the per-call `RequestOptions` under `.Core`. Each section names its own namespace. +All of them are generic across APIMatic .NET SDKs built by this generator. ## ServerOptions configuration for each Environment @@ -119,11 +120,16 @@ Notes: the per-attempt timeout is itself a retry trigger, a hung provider on a `GET` costs the *whole* budget (4 × `Timeout` + backoff), not one `Timeout`. A `TaskCanceledException` — from your own `CancellationToken`, or from `HttpClient.Timeout` — is **not** in the set and ends the call where it fires. -- **You never catch `TimeoutRejectedException`.** Once the retries are exhausted the SDK translates it, and - what reaches your `catch` is a `TaskCanceledException` — message *"The request was canceled due to the - configured RetryOptions.Timeout elapsing."* — whose `InnerException` is a `TimeoutException`. So the SDK's - own timeout and your own cancellation surface as the **same exception type**; tell them apart by the inner - exception, or by checking `ct.IsCancellationRequested`, not by the type you caught. +- **You never catch `TimeoutRejectedException` or the raw `HttpRequestException`.** Polly classifies the + raw exceptions *inside* the pipeline, so retry behaviour is exactly as described above; what leaves the + pipeline is wrapped once, by cause. An exhausted per-attempt `Timeout` reaches your `catch` as + `SdkTimeoutException` — message `"GET https://host/path received no response within 100 s."`, + `Timeout` the window that elapsed, the Polly rejection as `InnerException`. A connection reset, DNS + failure or dropped socket reaches you as `SdkConnectionException` — `"{call} could not be sent: …"` with + the `HttpRequestException` as `InnerException`. Both derive from `SdkException`, so the call's `Method` + and `RequestUri` are on the exception. Your **own** cancellation is the one thing that is never wrapped: + it surfaces as the usual `OperationCanceledException` / `TaskCanceledException`, so the *type* now tells + the SDK's timeout and your cancellation apart — no inner-exception sniffing needed. - **`Retry-After` overrides the computed backoff.** If the failing response carries a `Retry-After` header (delta-seconds or an HTTP date) that value is used as the delay instead of `Delay × BackOffFactor^n + jitter`. Both paths are then clamped to a hard **60s** ceiling that is not @@ -192,23 +198,24 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco 1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays above one. - Look at the operation's **own parameters**, not just the request model. The generator can put a - provider's idempotency key in any of three places, and only one of them is the body: + Look at **every member of the operation's `{Operation}Request` record**, not just its body model. The + generator can put a provider's idempotency key in any of three places, and only one of them is the body: - - a **header parameter** — a bare `string?` in the signature; - - a **form field** — routed into `FormUrlEncodedRequest.Create([...])`. - Do not search for a nullable type here: the same key can be declared **non-nullable** on one - operation and `string?` on the next. Match on the parameter reaching a - `new Param("...", x)` in the form body, not on its nullability; - - a field on the **request model**. + - a **header member** — a `string?` property on the record, which the method sends as + `new HeaderParam("...", request.X)`; + - a **form field** — a member the method routes into `FormUrlEncodedRequest.Create(new Param(...))`. + Do not search for a nullable type here: the same key can be declared **`required`** on one + operation and `string?` on the next. Match on the member reaching a + `new Param("...", request.X)` in the form body, not on its nullability; + - a field on the **body model** — the type of the record's `Body` member. - A model-only search finds nothing on the first two and concludes wrongly that the API offers no key. Ask - the contract sheet for the operation's full parameter list, not just its body shape. + A body-model-only search finds nothing on the first two and concludes wrongly that the API offers no key. + Ask the contract sheet for the operation's full request record, not just its body shape. - Note that **whether the provider actually rejects a duplicate value is not visible in the model**. Such a - field is typically just a nullable string, equally consistent with a uniqueness-enforced key and with a - free-text label. Verify against live traffic before relying on it; if it is not enforced, this gives you - nothing. + Note that **whether the provider actually rejects a duplicate value is not visible in the record or the + model**. Such a field is typically just a nullable string, equally consistent with a uniqueness-enforced + key and with a free-text label. Verify against live traffic before relying on it; if it is not enforced, + this gives you nothing. ⚠ **An `Idempotency-Key` header on the wire is usually not the provider's key.** The generator puts one on **every** non-GET operation and on no GET. Its value is almost always `Guid.NewGuid()`, injected @@ -217,8 +224,9 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco the caller's value. So the header's *presence* tells you nothing — only the **source of its value** does, and that is visible - in the signature: if no parameter feeds it, it is injected. Three consequences, all of which cut against - the reading you would naturally take: + in the method body, not the signature: `new HeaderParam("Idempotency-Key", request.X)` sends the + caller's key, `new HeaderParam("Idempotency-Key", Guid.NewGuid())` is injected. Three consequences, all + of which cut against the reading you would naturally take: - **An injected key makes nothing safe unless your provider consumes that exact header name.** Most APIs do not document `Idempotency-Key` as their mechanism, and on those it is inert. @@ -232,12 +240,13 @@ The four, **weakest guarantee first** — so do not read the numbering as a reco The trap is not that an injected key is useless; it is that it is *visible*. Seeing `Idempotency-Key` go out on the wire, or in a log, reads as evidence that idempotency is handled. Treat it as absent until you - have confirmed both that the provider consumes that header name and that a parameter feeds it — and note + have confirmed both that the provider consumes that header name and that a record member feeds it — and note the default `HttpMethodsToRetry` includes `PUT`, so a `PUT` is resent by the SDK itself under a header that probably means nothing to the provider. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. (Same reflex as an unreadable write response — - see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. + see `dotnet-error-handling`.) Detects a duplicate; does not prevent one. How: see *A write whose outcome + is unknown* below. 3. **A separate client for writes**, built with `Retry = RetryOptions.Disabled()`. Removes SDK-side resends entirely while keeping the per-attempt `Timeout`. Worth doing when you have widened `HttpMethodsToRetry` for the read path and need the write path held at one send regardless. @@ -277,6 +286,51 @@ introduced, not a duplicate you prevented. path turns one transient failure into a permanent refusal: every later attempt meets the stale claim and is turned away. Clear it once the outcome is settled, and expire it when it never is. +### ⚠⚠ A write whose outcome is unknown + +A transport failure on a write (`SdkConnectionException`, including `SdkTimeoutException`) means the write may +still have landed. ⚠⚠ **A `catch` that reports a failure without first re-reading provider state is the +defect.** Settle the outcome in code: + +1. **Re-read here**: look the write up by the reference you sent, or re-send it with the same idempotency key + where the operation declares one as a parameter (not the injected `Idempotency-Key` header). +2. **Record and sweep**: set a local state that means *unknown*, not failed, and write the sweep that re-reads + the provider and settles those rows. + +With no reference to look up by, match on what the write carried (recipient, amount or content, within a time +window). If the re-read finds nothing or fails too, the outcome is still unknown: record it for the sweep. If +the provider offers no way to find the write at all, stop and raise that with the user. ⚠⚠ A log line is +neither route. + +```csharp +catch (SdkConnectionException) // the write may still have landed +{ + var found = await client.{ApiGroup}.{LookupOperation}(new() { /* the reference you sent */ }, cancellationToken: ct); + // settle from what the provider holds for that reference +} +``` + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two instances racing. That needs a claim +that outlives the request and the process, taken atomically: + +```csharp +var reference = Deterministic(request); // from what the request is for — never Guid.NewGuid() +if (!await TryClaim(reference, ct)) // insert-or-fail: the store rejects the second claim, you catch it + return await LoadExisting(reference, ct); // may still be in progress — do not report it done +var result = await client.{ApiGroup}.{Operation}(/* `reference` as its idempotency key, if it has one */, cancellationToken: ct); +await Complete(reference, result, ct); +``` + +Build `TryClaim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`SemaphoreSlim`, `AsyncLocal`, a static dictionary), a single-host assumption or an +exists-then-insert read is no guard here: it lets both callers through — and documenting it as a known +limitation does not change that. + ## Bounding a call — the three layers, and which one is a total There are three places a bound can live, and **two of the three are per-attempt**: the two knobs named @@ -288,6 +342,9 @@ There are three places a bound can live, and **two of the three are per-attempt* | `HttpClient.Timeout` | one attempt | `100s` | **No** — same; see below | | `CancellationToken` you pass to the call | the whole call | none | **Yes** — the only one | +Both per-attempt timeouts stop when the response headers arrive; reading the body after that is bounded +only by your `CancellationToken` (on an event stream, also by `StreamReadTimeout`, below). + **`HttpClient.Timeout` is applied per attempt here, which is not obvious.** It is enforced by a CTS created inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so every retry gets a fresh full `Timeout`. @@ -295,15 +352,18 @@ inside each `SendAsync`, and the retry pipeline sits *above* `SendAsync` — so **Two per-attempt timeouts, and on a retryable verb only one of them ends the call.** `options.Retry.Timeout` expiring raises `TimeoutRejectedException` *inside* the pipeline, which *is* in the retry set — on an eligible verb the attempt is retried and the cost multiplies (that is the ≈407s row above); only once the retries run out does -the SDK convert it to the `TaskCanceledException` you catch. `HttpClient.Timeout` expiring raises -`TaskCanceledException` directly, which is *not* in the set — so it ends the call the first time it fires, -on any verb. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on -the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest hard bound on a hang: a `10s` -value bounds a hang at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs +the SDK convert it to the `SdkTimeoutException` you catch. `HttpClient.Timeout` expiring raises +`TaskCanceledException` inside the pipeline, which is *not* in the set — so it ends the call the first time +it fires, on any verb — and the SDK converts that too, into the **same** `SdkTimeoutException` (with +`Timeout = HttpClient.Timeout`), so a caller cannot tell from the type which knob fired; `Timeout` says +which. (On a non-retryable verb the distinction collapses: neither is retried and both end the call on +the first fire.) That asymmetry makes `HttpClient.Timeout` the cheapest bound on the wait for the response +headers: a `10s` value bounds that wait at ≈10s whatever the verb. What it does not bound is a provider that fails *retryably* just under the limit on every attempt — that still costs ≈ `4 × 10s + 7s ≈ 47s`. It is also the only bound left standing if you set `options.Retry.Timeout = null`. A -good backstop and the cheapest fix for a hang; still not a call budget. +good backstop and the cheapest fix for a hang before the headers; still not a call budget. (An *infinite* `HttpClient.Timeout` +never fires, and a `TaskCanceledException` under one is not the SDK's to explain — it passes through raw.) -Set all three — they catch different failures. The per-attempt bounds cap a single stalled socket; the token +Set all three — they catch different failures. The per-attempt bounds cap one attempt's wait for the headers; the token caps the sum, which is the only thing your caller experiences: ```csharp @@ -313,7 +373,7 @@ options.Retry = RetryOptions.Default() with { Timeout = TimeSpan.FromSeconds(10) // httpClient.Timeout = TimeSpan.FromSeconds(10); // per attempt, backstop — see dotnet-client-initialization using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // the whole call — the mechanism; -var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: cts.Token); // for WHERE it belongs, see below +var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: cts.Token); // for WHERE it belongs, see below ``` **Give the total bound one home, not one per call site.** A `CancellationTokenSource` written out at each @@ -336,7 +396,34 @@ Then every operation goes through `Bounded(...)`, and the budget is one value in **The invariant to check:** your worst case — `attempts × per-attempt timeout + total backoff` — must sit below the deadline your own caller is working to. If it does not, the caller times out first and your retries are burning provider capacity for a response nobody is still waiting for. A `TaskCanceledException` from your -own token is **not** retried (see Notes above), so the token cuts the call cleanly. +own token is **not** retried and **not** wrapped (see Notes above), so the token cuts the call cleanly and +surfaces as itself. + +## The clock — `TimeProvider` + +Every place the SDK reads time goes through **one** option, `options.TimeProvider` (a +`System.TimeProvider`, default `TimeProvider.System`): the retry backoff and the `Retry-After` arithmetic, +the per-attempt `Timeout`, the SSE idle window (`StreamReadTimeout`, below), OAuth2 token expiry, webhook +replay tolerance (its own `WebhookOptions.TimeProvider`) and the elapsed-milliseconds figure in the response +log line. There is no second clock inside the engine — a component that needs time is handed this one, and +never falls back to `TimeProvider.System` on its own. + +```csharp +options.TimeProvider = TimeProvider.System; // the default; set it only to substitute a clock +``` + +Under `services.Add{Api}Client(...)` the option is **seeded from the container before your `configure` +callback runs**: a `TimeProvider` you registered — in any order relative to the SDK registration — is picked +up, and a value you assign in `configure` wins over it. The SDK **never registers** a `TimeProvider` in your +container, so it neither collides with yours nor leaves one behind. The precedence is: explicit option > +container registration > `TimeProvider.System`. The same seeding applies to +`Add{Api}WebhookSignatureVerifier` for webhooks. + +The practical consequence is for tests: one fake clock on `options.TimeProvider` drives retries, idle +timeouts and token expiry together, with no real sleeps. Use a provider that **implements timers** — +`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the backoff and the idle +window are timers on the provider; a `GetUtcNow`-only fake throws when the SDK asks for one. See +**dotnet-testing** § *Fake the clock*. ## Pagination @@ -358,7 +445,7 @@ const int MaxPages = 100; int pages = 0; await foreach ({PageResponse} page in - client.{ApiGroup}.{Operation}(/* offset: 0, limit: 100, ... */, ct: ct).AsPages(ct)) + client.{ApiGroup}.{Operation}(new {Operation}Request { /* Offset = 0, Limit = 100, ... */ }, cancellationToken: ct).AsPages(ct)) { foreach (var item in page.{Items}) Process(item); @@ -373,13 +460,13 @@ total-count metadata on the page response lives** — the sample above uses it t equally when you need that metadata: ```csharp -await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(/* ... */, ct: ct).AsPages(ct)) +await foreach ({PageResponse} page in client.{ApiGroup}.{Operation}(request, cancellationToken: ct).AsPages(ct)) { // page carries its items plus whatever paging metadata the response declares } ``` -A failed page fetch throws `SdkException` mid-enumeration (see **dotnet-error-handling**). +A failed page fetch throws `ApiException` mid-enumeration (see **dotnet-error-handling**). ### ⚠⚠ Never leave a page loop unbounded @@ -415,15 +502,26 @@ Prefer to **narrow the query before you page it**: a provider-side date range, s `pageSize` that matches what the caller needs turns "walk everything" into a handful of pages. Paging the whole collection and filtering client-side is the slow path even when it terminates. -**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap, either -surface it to the caller or log it — never return a partial page set that reads like a complete one. +**A bound that silently truncates is a different defect from one that hangs.** When you hit the cap the +result is partial, and **that fact belongs in the result** — the caller never reads your logs, so a partial +set that looks complete is a *wrong* answer. Return it in whatever form this codebase already uses to +express a partial or continuable result: + +```csharp +// Shape, not a prescribed type — match the repository's existing result convention. +return Partial(results, nextPage: page); // the caller has to handle the partial case +``` + +If there is no such convention and the return type genuinely cannot change, throw rather than return +silently. ⚠⚠ **A `LogWarning` at the cap followed by `return results;` is the defect.** Log it as well, +never instead. **No-throw variant.** Where generated, a sibling `{Operation}Result` returns `Pageable, {Item}>` — the same streaming, but its **`.AsPages(ct)`** hands you an `ApiResult` per page that you inspect instead of it throwing: ```csharp -await foreach (var result in client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct).AsPages(ct)) +await foreach (var result in client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct).AsPages(ct)) { if (result.TryGetResponse(out var pageResponse)) // the page (items + any cursor/link metadata) { @@ -451,43 +549,52 @@ frames as the server emits them. `{Item}` is `string` for a plain-text stream, o event stream. ```csharp -using {RootNamespace}.Core.Exceptions; // SseException, SseTimeoutException, SseDeserializationException +using {RootNamespace}.Core.Exceptions; // SdkTimeoutException, ResponseDeserializationException, SdkConnectionException // await once to open the stream (an opening error surfaces here — see "Errors" below): -IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(ct: ct); +IAsyncEnumerable<{Item}> stream = await client.{ApiGroup}.{Operation}(cancellationToken: ct); try { await foreach (var frame in stream.WithCancellation(ct)) // each frame as the server emits it Process(frame); } -catch (SseTimeoutException ex) // no frame arrived within the idle-timeout window +catch (SdkTimeoutException ex) // no frame arrived within StreamReadTimeout +{ + // ex.Timeout — the idle window that elapsed +} +catch (ResponseDeserializationException ex) // a frame didn't match {Item} (JSON / parsed streams) { - // ex.IdleTimeout — the window that elapsed + // ex.TargetType — {Item}; ex.InnerException — the JsonException / FormatException } -catch (SseDeserializationException ex) // a JSON frame didn't match {Item} +catch (SdkConnectionException ex) // the connection dropped mid-stream { - // ex.RawFrame (offending payload) + ex.InnerException (the JsonException) + // ex.InnerException — the IOException / HttpRequestException } ``` -**Idle timeout.** A stalled stream is bounded by an **idle timeout** — the maximum wait **between frames** — -which throws `SseTimeoutException` (rather than hanging) when it elapses. This is **not** a client-options -property (there is no `StreamReadTimeout`); the idle window is a `TimeSpan?` carried on the SSE response -itself, defaulting to **none** — a null window disables the check. When it does fire, -`SseTimeoutException.IdleTimeout` reports the window that elapsed. +**Idle timeout.** A stalled stream is bounded by the **`StreamReadTimeout` client option** — the maximum +wait **between frames**, a `TimeSpan?` on `{Api}ClientOptions` defaulting to **60 s**; set it to `null` to +wait indefinitely. It bounds only the wait for the server between frames, never your own processing time +inside the loop. When it elapses the SDK tears the stream down and the enumeration throws +`SdkTimeoutException` with `Timeout` set to the window that elapsed — the same leaf that reports a +per-attempt timeout on an ordinary call, so one `catch (SdkTimeoutException)` covers both. The window is +measured on `options.TimeProvider` (see *The clock* above), so a fake clock drives it in tests. -**Errors** (all under `{RootNamespace}.Core.Exceptions`): -- **Before the stream opens** — the opening `await` throws `SdkException`, with `TError` the same +**Errors** (all under `{RootNamespace}.Core.Exceptions`, all `SdkException`s that name the call): +- **Before the stream opens** — the opening `await` throws `ApiException`, with `TError` the same two-case shape as any operation: a typed `{Operation}Error` (Case A) or `RawError` (Case B), per what the operation declares (see **dotnet-error-handling**). -- **While enumerating** — both of the following derive from a common `SseException` base (catch `SseException` - to handle either): - - `SseTimeoutException` — no frame arrived within the idle-timeout window; carries `IdleTimeout`. - - `SseDeserializationException` — a frame couldn't be deserialized to `{Item}` (JSON streams); carries the - `RawFrame` text and the underlying `JsonException` as `InnerException`. +- **While enumerating** — there is no SSE-specific exception type; the family's ordinary leaves apply: + - `SdkTimeoutException` — no frame arrived within `StreamReadTimeout`; carries `Timeout`. + - `ResponseDeserializationException` — a frame couldn't be parsed as `{Item}`; carries `TargetType` and + the underlying `JsonException` (or `FormatException` for a parsed scalar) as `InnerException`. The raw + frame text is **not** on the exception or in its message. A plain-`string` stream cannot raise it. + - `SdkConnectionException` — the connection dropped mid-stream (`"{call} could not read the response + body: …"`, the `IOException` / `HttpRequestException` as `InnerException`). - Retries do **not** apply once the stream is open; cancel via the `CancellationToken` - (`stream.WithCancellation(ct)`) to stop early. + (`stream.WithCancellation(ct)`) to stop early — your cancellation surfaces as `OperationCanceledException`, + never wrapped. ## Logging @@ -670,10 +777,9 @@ Checklist for the first printed request: path param, so that is only reachable through a hand-set `BaseUrl`; and it prints percent-encoded, `%7Bid%7D`, not as `{id}`.) 3. each **path-param segment** is the value the API expects — for an enum that is the **wire value** - (frequently ALL-CAPS), not the C# member name; the generated constants carry it. `FromValue("...")` is - the risk, but not in the way you would guess: a string that matches a declared constant - *case-insensitively* is normalised to the declared casing, so only a value matching **no** constant at - all reaches the URL verbatim — a typo, not a casing slip; + (frequently ALL-CAPS), not the C# member name; the generated constants carry it. Nothing normalises + casing and there is no public factory, so the only way an unexpected string reaches the URL is a value + the server itself sent and your code echoed back — check `IsKnownValue()` before reusing one as input; 4. the query params you set actually appear in the query string — remembering that a value is masked unless its key is on the known-safe list (`cursor`, `dates`, `datetime`, `limit`, `offset`, `page`, `since`, `size`, `strings`), so for anything else you are checking that the key is present, not what it diff --git a/plugins/alpaca/skills/dotnet/dotnet-error-handling/SKILL.md b/plugins/alpaca/skills/dotnet/dotnet-error-handling/SKILL.md index 14ea4ce00..b4fd301b9 100644 --- a/plugins/alpaca/skills/dotnet/dotnet-error-handling/SKILL.md +++ b/plugins/alpaca/skills/dotnet/dotnet-error-handling/SKILL.md @@ -15,7 +15,7 @@ description: Error and exception handling for an APIMatic-generated .NET SDK in > matches**. An API can be all Case B, nearly all Case A, or a mix. Endpoint methods **throw on non-success responses** by default (for a non-throwing alternative, see the -**`ApiResult`** section below). The thrown type is always the generic `SdkException` — but `TError` +**`ApiResult`** section below). The thrown type is always the generic `ApiException` — but `TError` comes in **two shapes**, depending on the operation: - **Typed model (Case A)** — a per-operation `{Operation}Error` (subclass of `ApiError`) exists under @@ -25,14 +25,45 @@ comes in **two shapes**, depending on the operation: read the status and body straight off `ex.Error`. How common this is, is an API fact — some APIs are almost entirely Case B, others almost entirely Case A; each operation's map row says which it is. -`SdkException` is declared `public sealed class SdkException : Exception` with **no** +`ApiException` is declared `public sealed class ApiException : ApiException` with **no** `where TError : ApiError` constraint — which is exactly why `TError` can be either an `ApiError` model or a `RawError`. +### The `SdkException` family + +`ApiException` is one leaf of a small hierarchy. **Everything the SDK raises for a call derives from +the abstract `SdkException`**, and every level adds the members its meaning needs: + +``` +SdkException (abstract) Method, RequestUri — the call that failed; every Message starts with it +├─ ApiException (abstract) StatusCode, Headers, ContentType — the server answered +│ ├─ ApiException (sealed) Error — an error status; TError is the operation's error type +│ └─ ResponseDeserializationException TargetType — a body (success OR error) did not match the declared type +├─ SdkConnectionException — — no usable response: could not send, or could not read the body +│ └─ SdkTimeoutException (sealed) Timeout — an attempt, the transport, or an SSE stream went silent +└─ AuthSchemeException (sealed) SchemeFailures — a credential could not be applied +``` + +Three rules follow from the shape, and the rest of this skill applies them: + +- **Catch specific to general.** `ApiException` means *the server answered* (you have a status); + `SdkConnectionException` means *it did not*; `SdkException` is *everything the SDK raises*. A ladder that + handles only `ApiException` is incomplete — see *Connection failures* below. +- **The raw cause is always `InnerException`** — the `JsonException`, the `HttpRequestException`, the Polly + timeout rejection, the token endpoint's own failure — never the thing you catch. +- **Your own cancellation is never wrapped.** An `OperationCanceledException` from a token *you* cancelled + passes through untouched; a `catch (SdkException)` does not see it. + +The messages are a contract, asserted verbatim by the SDK's own tests, and each names the call with its query +stripped: `"GET https://host/path returned 400 (BadRequest)."`, `"{call} could not be sent: …"`, +`"{call} could not read the response body: …"`, `"{call} received no response within 100 s."`, +`"{call} returned a body that could not be deserialized into {Type}."`. A log line therefore identifies the +failing call without a typed catch. + These types live in **distinct** namespaces — `Core.*` is **not** a single namespace, so don't assume -`ApiError` sits with `SdkException` under `Core.Exceptions`: +`ApiError` sits with the exceptions under `Core.Exceptions`: -- `SdkException` → `{RootNamespace}.Core.Exceptions` +- the whole `SdkException` family → `{RootNamespace}.Core.Exceptions` - `ApiError` **and** `RawError` → `{RootNamespace}.Core.ErrorResponse` - the per-operation `{Operation}Error` models (e.g. `CreateWidgetError`) → `{RootNamespace}.Errors` @@ -41,7 +72,7 @@ spell the `out` types out or use `out var`: | namespace | what you need from it | | --- | --- | -| `{RootNamespace}.Core.Exceptions` | `SdkException` — the exception itself | +| `{RootNamespace}.Core.Exceptions` | `ApiException` — the exception itself (and the rest of the family) | | `{RootNamespace}.Errors` | the `{Operation}Error` you name in the `catch` | | `{RootNamespace}.Core.ErrorResponse` | `RawError`, which the inherited `TryGetRawError` hands back | | *depends on the body* | the **typed body** a `TryGet…` yields — see below | @@ -62,16 +93,18 @@ checked. ## Catch the exception -`SdkException` exposes a single property — `public required TError Error { get; init; }`, the parsed -error model. What `Error` *is* depends on the case (below). +`ApiException` adds a single property of its own — `public required TError Error { get; init; }`, +the parsed error model — on top of what it inherits: `StatusCode`, `Headers` and `ContentType` from +`ApiException`, and `Method` and `RequestUri` from `SdkException`. So the HTTP status is **always** on the +exception, in both cases; what `Error` *is* depends on the case (below). -**Read the error directly off the strongly-typed `SdkException` — never use reflection.** The +**Read the error directly off the strongly-typed `ApiException` — never use reflection.** The concrete `TError` is known right there at the `catch` (Case A: the typed `{Operation}Error`; Case B: `RawError`), so `ex.Error` and the accessors on it are reachable directly. Do **not** dig the body out via reflection (`ex.GetType().GetProperty("Error")`, then discovering and `Invoke`-ing the `TryGet*` methods): it compiles, but it is brittle glue that reinvents what a per-operation typed `catch` gives you for free — the concrete type is already known, so no runtime discovery is needed. Catch the concrete -`SdkException<{Operation}Error>` (or `SdkException`) and read `ex.Error` straight off it. +`ApiException<{Operation}Error>` (or `ApiException`) and read `ex.Error` straight off it. ### Which `TError` does an endpoint throw? @@ -86,25 +119,26 @@ such line, but **not every generated operation has a doc block at all**, so its about the error case; fall back to the contract sheet: ```csharp -/// of when the server returns an error response. +/// of when the server returns an error response. ``` -`SdkException{TResult}` is boilerplate (identical on every method — `{TResult}` is the doc-comment's generic +`ApiException{TError}` is boilerplate (identical on every method — `{TError}` is the doc-comment's generic placeholder, **not** the type you catch). The type named after **`of `** is the actual `TError`: -- `… of …` → catch `SdkException<{Operation}Error>` (Case A). -- `… of …` → catch `SdkException` (Case B). +- `… of …` → catch `ApiException<{Operation}Error>` (Case A). +- `… of …` → catch `ApiException` (Case B). -Equivalently, when grounding from the SDK source (the clone the getting-started skill describes): a +Equivalently, when grounding from the SDK source (the SDK source the getting-started skill locates): a `{Operation}Error` type exists under `Errors/` **only** for Case-A operations; if there is no -`{Operation}Error`, the operation throws `SdkException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the -dangerous one. `SdkException` fails to compile when no such type exists — that guess the +`{Operation}Error`, the operation throws `ApiException`. Guessing wrong is only *sometimes* a compile-time error, and the direction that looks safe is the +dangerous one. `ApiException` fails to compile when no such type exists — that guess the compiler does catch. But every `{Operation}Error` in the SDK *is* a real type, so naming the **wrong one** — a neighbouring operation's error type — compiles cleanly and then **never matches at runtime**, because -`SdkException
` and `SdkException` are unrelated closed generics. The exception sails past your -`catch` and surfaces somewhere else, or not at all until it is an unhandled failure. Take the case from the -contract sheet; the compiler is not a check on this. +`ApiException` and `ApiException` are unrelated closed generics. The exception sails past your +typed `catch` — and lands in a `catch (ApiException ex)` if you have one, where `ex.StatusCode` is still +readable but the typed body is not. Take the case from the contract sheet; the compiler is not a check on +this, and the non-generic base is a safety net, not a substitute. ### Case A — operation has a typed `{Operation}Error` model @@ -126,17 +160,18 @@ memory: (see below) — it only fires for statuses that have no more-specific accessor. ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // ApiError, RawError using {RootNamespace}.Errors; // {Operation}Error types, e.g. CreateWidgetError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException<{Operation}Error> ex) +catch (ApiException<{Operation}Error> ex) { + // ex.StatusCode / ex.Headers are always here; the body is behind the accessors below. // ONE branch per public TryGet* declared on {Operation}Error — copy the exact names from the class // under Errors/. The TryGet{...} names below are PLACEHOLDERS; yours are named after this operation's // responses (a typed body may be a model OR a scalar such as TryGetString/TryGetLong). @@ -176,24 +211,24 @@ that has a typed body it finds nothing and falls back to `e.ToString()` — a ba per-operation `catch` block**, where the concrete `{Operation}Error` type is known; reserve shared code for the `RawError`/transport fallback only. -### Case B — operation throws `SdkException` +### Case B — operation throws `ApiException` For operations with no `{Operation}Error` type (none under `Errors/`), `ex.Error` **is** a `RawError` — there are no `TryGet*` accessors and no `TryGetRawError`; read the status and body straight off it: ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Core.ErrorResponse; // RawError try { - var response = await client.{ApiGroup}.{Operation}(/* ... */, ct: ct); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: ct); // use response } -catch (SdkException ex) +catch (ApiException ex) { RawError raw = ex.Error; // the error model IS RawError here - Console.Error.WriteLine($"HTTP {(int)raw.StatusCode}"); + Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}"); // same value as raw.StatusCode Console.Error.WriteLine(raw.ReadAsString()); // or raw.ReadAsJson() } ``` @@ -224,7 +259,7 @@ using {RootNamespace}.Errors; // {Operation}Error (Case A only) // No try/catch — the *Result variant returns the outcome instead of throwing. ApiResult<{ReturnType}, {Operation}Error> result = - await client.{ApiGroup}.{Operation}Result(/* ... */, ct: ct); + await client.{ApiGroup}.{Operation}Result(request, cancellationToken: ct); if (result.TryGetResponse(out var response)) // success { @@ -249,48 +284,71 @@ var summary = result.Match(onSuccess: r => "ok", onFailure: e => "failed"); // Tuple deconstruction: var (isSuccess, response, error) = result; -// Bridge back to the throwing behavior (returns the response or throws SdkException<{TError}>): +// Bridge back to the throwing behavior (returns the response or throws ApiException<{TError}>): {ReturnType} value = result.GetResponseOrThrow(); ``` ## Connection failures, and guarding every call -The exception types above cover API errors (the server replied with a non-2xx status). They do -**not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. -Those come through as `HttpRequestException` / `TaskCanceledException`, which a -`catch (SdkException<...>)` will not match. If that catch is your only guard, a connection failure -escapes and takes down whatever was running the call. +The `ApiException` catches above cover API errors (the server replied with a non-2xx status). They +do **not** cover connection failures — host unreachable, DNS failure, dropped connection, or timeout. Those +come through as **`SdkConnectionException`** (could not send, or could not read the body) and its leaf +**`SdkTimeoutException`** (an attempt, the transport, or an SSE stream went silent — `Timeout` says which +window), which a `catch (ApiException<...>)` will not match. If that catch is your only guard, a connection +failure escapes and takes down whatever was running the call. The raw `HttpRequestException` is the +`InnerException`; you never catch it directly. + +**A file download is the exception:** a drop while you read the returned `BinaryContent` throws a raw +`IOException` from your read, not `SdkConnectionException`. **Convert connection failures to your own error type in one place.** If you wrap the SDK behind your own abstraction (a client interface, a service, a repository), catch connection failures at that boundary and rethrow the same error type you already use for API errors — so the rest of the -code has a single failure type to handle instead of two unrelated ones: +code has a single failure type to handle instead of two unrelated ones. Order the ladder as the +hierarchy reads — leaves first, `SdkException` last: ```csharp // Keep the arms the operations you call actually need — each operation's map row names its case. -catch (SdkException<{Operation}Error> ex) // a Case A operation — typed error; may carry NO status +catch (ApiException<{Operation}Error> ex) // a Case A operation — typed error; ex.StatusCode is on it { // Carry the body's identity fields (the provider's own error code or name, any correlation // id) — the typed body is the only thing that carries them, and it does not outlive this // catch. Check the accessors in the SAME order as the Case A ladder above: every typed // accessor this operation declares (names from its map row), then the RawError-yielding - // arms LAST — those are the typed-path branches that DO carry a status. + // arms LAST. Carry ex.StatusCode alongside whichever branch fires. if (ex.Error.TryGet{Body}(out {Body} e)) - throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex); + throw new {ProviderException}(e.{MessageField} /* + identity fields — names from the model's map row */, ex.StatusCode, ex); if (ex.Error.TryGetRawError(out RawError raw)) - throw new {ProviderException}($"HTTP {(int)raw.StatusCode}", raw.StatusCode, ex); - throw new {ProviderException}("unrecognised error shape", ex); + throw new {ProviderException}($"HTTP {(int)ex.StatusCode}", ex.StatusCode, ex); + throw new {ProviderException}("unrecognised error shape", ex.StatusCode, ex); } -catch (SdkException ex) // a Case B operation — RawError carries the status +catch (ApiException ex) // a Case B operation — no typed body, just the status { // Carry the status. The boundary ladder below is the only place it can be read back, and a // status dropped here cannot be recovered anywhere downstream. - throw new {ProviderException}("...", ex.Error.StatusCode, ex); + throw new {ProviderException}("...", ex.StatusCode, ex); +} +catch (ResponseDeserializationException ex) // the server answered, the body did not match — see below +{ + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); +} +// On a WRITE, the two catches below mean the outcome is unknown: the write may still have landed. Settle it +// at the write's call site, where its reference is in scope — see "A write whose outcome is unknown" in +// dotnet-configuration-resilience. +catch (SdkTimeoutException ex) // an attempt, the transport, or a stream went silent +{ + throw new {ProviderException}($"provider did not answer within {ex.Timeout}", ex); // no status +} +catch (SdkConnectionException ex) // could not send / could not read the body (not a file you read later) +{ + throw new {ProviderException}("provider unreachable", ex); // no status — nothing usable answered } -catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) // connection failure +catch (AuthSchemeException ex) // OUR credential could not be applied — see Notes { - throw new {ProviderException}("provider unreachable", ex); // no status — nothing answered + throw new {ProviderException}("provider credentials rejected", ex); } +// Do NOT add `catch (OperationCanceledException)`: the caller's own cancellation is not a provider +// failure and the SDK never wraps it — let it propagate. ``` **Guard every call site, not just the ones that change data.** It is easy to wrap the calls that @@ -318,21 +376,17 @@ delivers.** Your error type needs to carry something that separates "you sent so from "the provider is down". The reflex is the HTTP status; whether you *can* carry one is an API fact, settled from the contract sheet before the boundary is written: -- **Operations that throw `SdkException`** (Case B) hand you `StatusCode` directly — - carry the status and key the ladder on it. A provider **4xx** the caller can act on surfaces as - that same client **4xx**; transport failures and unknowns surface as **5xx**. -- **Operations with a typed `{Operation}Error`** (Case A) need checking before you assume a - status: `SdkException` itself has exactly one member, `Error`, with no `StatusCode` on - it, and on some APIs the typed bodies carry no status either. There the discriminator is the - body's own **identity fields** — the provider's error code or name, fine-grained issue - codes, a correlation id — which usually say more than a status would anyway (whether each field is `required` or nullable - is on the model's map row). And if the typed body itself declares a status field, that - body-carried status qualifies for the status-keyed ladder below — read it from the body and - key on it. A typed error - class can also route several documented statuses into ONE accessor (so "caller's fault or - provider's" may not be answerable from a status even in principle), while some operations add - status-specific `TryGet…(out RawError)` accessors — typed-path branches that DO carry one. The - operation's map row lists all of it. +- **Every `ApiException` carries `StatusCode`** — Case A and Case B alike, and the + `ResponseDeserializationException` too — so the transport status is always available to carry + and to key the ladder on. A provider **4xx** the caller can act on surfaces as that same client + **4xx**; transport failures and unknowns surface as **5xx**. +- **Operations with a typed `{Operation}Error`** (Case A) usually have a **better** discriminator + than the status: the body's own **identity fields** — the provider's error code or name, + fine-grained issue codes, a correlation id — which say more than a status would (whether each + field is `required` or nullable is on the model's map row). A typed error class can route + several documented statuses into ONE accessor, so the accessor that fired and `ex.StatusCode` + are two different facts — carry both. Some operations add status-specific + `TryGet…(out RawError)` accessors as well. The operation's map row lists all of it. - **Accessor names differ per operation** — they embed the body type (`TryGet{Body}`), so a guessed name is a compile error (`CS1061`), not a runtime surprise. Take each name from the operation's own map row every time. @@ -384,44 +438,43 @@ bucket; validation, conflict and not-found are the caller's to fix. And keep the 5xx: a status or code you have not mapped is an unknown, not a caller error — the provider can add one without warning you. -**If typed errors on your SDK carry no status and you genuinely need the transport status** — for -metrics, or a provider-availability SLA — an `SdkHook.OnResponse` hook sees every response before -the SDK maps it (`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see -**dotnet-configuration-resilience** § Hooks). Two caveats: hooks run once per attempt, so under -retry "the status" means the last attempt's; and a client-wide hook needs an `AsyncLocal` or a -scoped service to reach the catch site — a per-call hook can simply close over a local. Do not use -it to drive ordinary error mapping — the body's identity fields are the better key and need none -of that machinery. +**Status and headers on the *success* path** are not on the return value — for metrics on a 2xx, or +rate-limit headers, use the `{Operation}Result` sibling (above) or an `SdkHook.OnResponse` hook +(`options.Hooks` client-wide, or `requestOptions.Hooks` per call — see +**dotnet-configuration-resilience** § Hooks). On the *error* path you need neither: every +`ApiException` already carries `StatusCode` and `Headers`. Hooks run once per attempt, so under retry +"the status" they observe means the last attempt's. **An unreadable body is not one case but two — decide which before you map it.** An unreadable **success** body is genuinely unknown: 5xx. An unreadable **error** body is not — the provider rejected the request and only the *detail* was lost, so answering 5xx tells a retrying caller to -keep retrying something that can never succeed. The trap below shows how the second case arises and -what it costs you. - -**A success status with a broken body is a third failure kind — catch it and sanitize.** The -server can return a 2xx whose body no longer matches the model, so the SDK throws -`System.Text.Json.JsonException` while deserializing it. This matches **neither** a -`catch (SdkException<...>)` (no error status was returned) **nor** a transport catch — so it -escapes unhandled, and if it reaches a generic handler that writes `exception.Message` the -response leaks `System.Text.Json.*` type and JSON-path detail. Catch it at the same boundary and -convert it to your own error type with a caller-safe message: - - catch (System.Text.Json.JsonException ex) +keep retrying something that can never succeed. The exception below tells you which you have. + +**A body that does not match the model is a third failure kind — catch it and sanitize.** The +server can return a body that no longer matches the declared type; the SDK wraps the +`System.Text.Json.JsonException` in **`ResponseDeserializationException`** — an `ApiException` +(so `StatusCode`, `Headers` and `ContentType` are on it) that also names the `TargetType` it was +building, with the `JsonException` as `InnerException`. It is **not** an `ApiException`, so a +ladder with only typed catches lets it through — and if it reaches a generic handler that writes +`exception.Message` the response leaks the SDK type name and the call URL. Catch it at the same +boundary and convert it with a caller-safe message, and **read `ex.StatusCode` to tell the two cases +apart**: + + catch (ResponseDeserializationException ex) { - throw new {ProviderException}("The provider returned a response that could not be processed.", ex); + // 2xx → the outcome is unknown: the call may have succeeded and we cannot read the result. + // else → the provider rejected the request; only the error DETAIL was lost. Keep the status. + throw new {ProviderException}("The provider returned a response that could not be processed.", ex.StatusCode, ex); } -**The same exception also arrives from the *error* path, and means the opposite.** `{Operation}Error` -models are generated per operation and can disagree with the body the API really sends on that -status. When they do, the deserialization runs *while the error object is being constructed*, so the -`JsonException` **replaces** the `SdkException` — your typed `catch` never fires, and the HTTP status -is gone with it. Identical exception type, opposite meaning: the 2xx case is "outcome unknown", this -case is "you were rejected and I lost the reason". A single `catch (JsonException)` that maps both to -a 5xx is wrong half the time — see *Keep distinct failures distinct* above. Either treat that -operation's parse failure as the rejection it is, or capture the status before the SDK discards it -(an `SdkHook.OnResponse` hook sees it, at the cost of carrying HTTP state to your boundary out of -band — and across a retry pipeline, of being ambiguous about *which* attempt you recorded). +**The error-path variant used to lose the status; it no longer does.** `{Operation}Error` models are +generated per operation and can disagree with the body the API really sends on that status. When they +do, the parse fails *while the error object is being constructed* and the SDK throws +`ResponseDeserializationException` **instead of** `ApiException<{Operation}Error>` — your typed +`catch` never fires. But the status, headers and content type ride on the exception, so "you were +rejected and I lost the reason" is fully expressible: map it by `ex.StatusCode` like any other +`ApiException`, not as a 5xx. The SDK deliberately does *not* degrade an unparseable error body to +`RawError`: a silent fallback would hide the contract drift that is the real defect here. **Never map a parse failure onto a domain *absence*.** "I could not read the answer" is not "the provider said no." It is tempting on a lookup — an unreadable body and a genuine miss both leave you @@ -435,16 +488,27 @@ caller-safe message — never surface `ex.ToString()` or `exception.Message` fro framework exception on the wire (the same leak the `ApiError.ToString()` bare-type-name trap above produces). +**A successful reply can still hand you `null`.** An omitted optional member reads `null`, +and so does a JSON `null` on a required string, list, model or enum. An absent required member, or `null` on a +number, `bool` or `Guid`, throws `ResponseDeserializationException`. Check the members you depend on after the +call, and every object you reach them through (`x.Y` before `x.Y.Z`). + ## Notes -- On an SDK with **multiple/composite auth schemes**, a call can also throw `AuthSchemeException` - (under `{RootNamespace}.Core.Exceptions`) — an auth *application* failure, not an API error — when the - configured schemes can't be satisfied; it carries `IReadOnlyList SchemeFailures` and is **not** - an `SdkException`, so a `catch (SdkException<...>)` won't match it — catch it separately. (An SDK - whose API declares a single scheme never hits this.) +- A call can also throw `AuthSchemeException` (under `{RootNamespace}.Core.Exceptions`) — a credential + could not be *applied*, which is not an API error. It is an `SdkException` (so it names the API call you + made) but **not** an `ApiException`, so the typed catches above won't match it — give it its own arm, as + the ladder does. It carries `IReadOnlyList SchemeFailures`: with a **single** failing scheme, + that failure is also the `InnerException` directly and the list's only entry; with **several** (an SDK + whose API composes schemes with OR and none succeeded), `InnerException` is an `AggregateException` of + them. The classic cause is an OAuth2 token endpoint refusing the client credentials: the token endpoint's + own `ApiException` (pointing at the token URL, with its status) is the **cause** inside the + `AuthSchemeException`, not what your `catch` sees — so log `InnerException` to learn *why* auth failed. + An `AuthSchemeException` is never wrapped in another. - Retries happen automatically before an exception is thrown, and `HttpMethodsToRetry` (`GET/HEAD/PUT/OPTIONS` by default) gates **every** trigger — status, transport fault, and the SDK's own - per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE` surfaces on the first attempt, with no - resend. That is not the same as "the write did not happen": a transport failure may have been thrown after - the bytes reached the provider, so the outcome is *unknown* and the caller needs to be told that rather - than "it failed". See **dotnet-configuration-resilience**. + per-attempt timeout alike. So an error on a `POST`/`PATCH`/`DELETE`, or on a call that sends a file, + surfaces on the first attempt, with no resend. That is not the same as "the write did not happen": a transport failure (`SdkConnectionException`) + may have been thrown after the bytes reached the provider, so the outcome is *unknown* and the caller needs + to be told that rather than "it failed". A per-attempt timeout that outlives its retries surfaces as + `SdkTimeoutException`. See **dotnet-configuration-resilience**. diff --git a/plugins/alpaca/skills/dotnet/dotnet-getting-started/SKILL.md b/plugins/alpaca/skills/dotnet/dotnet-getting-started/SKILL.md index b7a7503f4..fe69d6a29 100644 --- a/plugins/alpaca/skills/dotnet/dotnet-getting-started/SKILL.md +++ b/plugins/alpaca/skills/dotnet/dotnet-getting-started/SKILL.md @@ -1,120 +1,123 @@ --- name: "dotnet-getting-started" -description: "Trader API .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-alpaca is the workflow that uses it." +description: "Alpaca .NET SDK identity and lookup layer (.NET/C# only) you load directly — install, root namespace, environments, auth pattern, and the SDK map (shipped inside the SDK source repo): every operation signature and error case by lookup, plus the source file declaring each model, enum and error type. Load it for every contract fact, never memory; dotnet-integrate-alpaca is the workflow that uses it." --- -# Getting started with the Trader API .NET SDK +# Getting started with the Alpaca .NET SDK -> **Who this skill is for.** This is the **map layer**: load it to ground every Trader API SDK fact. `dotnet-integrate-alpaca` is the workflow that drives it — you collect the contracts for the work in scope into `trader-api-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. +> **Who this skill is for.** This is the **map layer**: load it to ground every Alpaca SDK fact. `dotnet-integrate-alpaca` is the workflow that drives it — you collect the contracts for the work in scope into `alpaca-plan.md`, implement from that sheet, and come back to the one map page a row cites for any fact the sheet is missing. Every contract fact comes from here, never from memory. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated .NET SDK (auth, calling endpoints, models, error handling, retries, testing), see the companion API-agnostic skills: `dotnet-client-initialization`, `dotnet-authentication`, `dotnet-calling-endpoints`, `dotnet-models`, `dotnet-error-handling`, `dotnet-configuration-resilience`, `dotnet-testing`. **The SDK map and these companion skills are complementary — load both.** The map (generated from the SDK source, which remains the ground truth) is authoritative for the SDK's *surface* (signatures, model shapes, enums, which error type an operation throws); the companion skills are the *usage layer* on top — the best-practice way to call each piece and the gotchas a signature cannot show. Reading the map or source does not remove the need to load the skill for that step, so at each step below, load the companion *and* confirm names against the map. -> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch GitHub files ad hoc, and do **not** grep or run other expensive searches over the clone. +> **Ground every signature, model, enum, and error type in the SDK map** (`sdk-map.md` + `map/operations/`, inside the SDK source repo — see the *SDK source* section for obtaining it). The map is **method-first**: operation signatures, error cases with their `TryGet…` accessors, pagination and server/auth wiring are on its pages by lookup — and for every type an operation names, the map names the **file that declares it**, so a shape question (fields, wire names, enum values, union accessors) is one targeted file open, never a search. When a map-sourced name fails to compile, re-read the file its row names; **never fill the gap from memory.** Do **not** decompile or reflect over the installed reference, do **not** fetch SDK files from the web ad hoc, and do **not** grep or run other expensive searches over the clone. ## SDK identity | | | | --- | --- | -| API | Trader API | +| API | Alpaca | | NuGet package | *not published to NuGet — see *Install* below* | | Source repo | https://github.com/context-plugins/alpaca-csharp-sdk (branch `main` — the ref this map documents) | -| Root namespace | `TraderApi` (the `using` namespace) | -| Client class | `TraderApiClient` | -| Options class | `TraderApiClientOptions` | +| Root namespace | `Alpaca` (the `using` namespace) | +| Client class | `AlpacaClient` | +| Options class | `AlpacaClientOptions` | | Auth | **API key** in the `APCA-API-KEY-ID` header — set `ApiKey`
**API key** in the `APCA-API-SECRET-KEY` header — set `ApiSecret` | | Environments | `ServerEnvironment.Paper` *(default)* → `https://paper-api.alpaca.markets`
`ServerEnvironment.Live` → `https://api.alpaca.markets` | -| Log environment variable | `TRADERAPICLIENT_LOG` | -| Target framework | `netstandard2.0` | +| Log environment variable | `ALPACACLIENT_LOG` | +| Target framework | `netstandard2.0` (C# `LangVersion 14`) | The table above is **orientation, not a copy-paste recipe** — it gives you the names and facts (install, namespaces, the auth *pattern*, the environments), while the actual integration code comes from the companion skills. Load every one the contract sheet's REQUIRED READING names before you start implementing (`dotnet-integrate-alpaca` makes that binding); the **Integration workflow** below says which governs which step. ## Namespaces (using-directives) -The SDK splits its public types across **separate child namespaces**. C# does **not** import child namespaces transitively, so `using TraderApi.Models;` alone does **not** make enums, union types, or error types visible — you get `CS0103`/`CS0246` ("name/type does not exist") on build. Add a separate `using` for each kind of type you reference: +The SDK splits its public types across **separate child namespaces**. C# does **not** import child namespaces transitively, so `using Alpaca.Models;` alone does **not** make enums, union types, or error types visible — you get `CS0103`/`CS0246` ("name/type does not exist") on build. Add a separate `using` for each kind of type you reference: | Type | Namespace | | --- | --- | -| `TraderApiClient` | `TraderApi` | -| `TraderApiClientOptions` | `TraderApi` | -| `ServerEnvironment` | `TraderApi.Servers` | +| `AlpacaClient` | `Alpaca` | +| `AlpacaClientOptions` | `Alpaca` | +| `ServerEnvironment` | `Alpaca.Servers` | Models, enums, union types and the generated error types each sit in their own child namespace. The map's **Namespaces by content type** table maps each source directory to its namespace, so read the namespace off the path the map gives for the type; anything left ambiguous is settled by the `namespace` declaration at the top of the type's own file. ## Install — from source -This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly, then write the `using` directives from the table above. That build reference is a separate thing from the read-only reference clone the *SDK source* section describes, which carries the map and is never part of the build. +This SDK is **not published to a package feed**, so there is no `dotnet add package` for it. Build it from its repository — , branch `main` — and reference the resulting assembly from the project that will call Alpaca, then write the `using` directives from the table above. + +> **Build against the assembly built from that repository, and do not copy the SDK source into your repository.** A copy is a fork: the next time the SDK is regenerated the copy stays behind, and the map then describes code you are not building against. The map is read from the read-only **reference clone** the *SDK source* section below describes, which lives in the system temp directory, is obtained at the start of SDK work and is never part of the build. ## SDK map — look up first, open the named file second, never grep -The SDK ships a generated map **inside its source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Obtain the source first (next section); every path below is relative to the SDK root in the clone: +The SDK ships a generated map **inside the SDK source repo**, at the SDK root — the map and the SDK regenerate together in one repo, so they cannot drift apart. The map is **method-first**: operation contracts are on its pages; model, enum and error **shapes are deliberately not duplicated there** — the map names the file declaring each type, and you read the shape in that file. Every path below is relative to the SDK root in the clone — obtain it first (next section): - **`sdk-map.md`** — the index and the read-once pages: SDK identity, client construction and the full options surface, the error-handling model (Case A typed / Case B raw), the **defaults table** for operations, the model/enum/error directory conventions, *Namespaces by content type*, and *Servers & auth*. -- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with must-pass-explicitly params, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. -- **Shapes live in the source, one type per file.** Every file under `Models/` and `Errors/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `Models/`, enums `Models/Enums/`, typed errors `Errors/`) and read the fields, `required` flags, `[JsonPropertyName]` wire names and enum values from that one file. The compiler is the backstop: a wrong name fails to build. +- **`map/operations/`** — one page per controller, one block per operation: the exact C# signature with its request record's required members, the return type, the error case (A: typed `{Operation}Error` with its `TryGet…` accessors and their statuses / B: `RawError`), pagination and server-group bullets **only where they differ from the defaults**, and a **Type sources** table naming the file that declares every type the operation mentions. +- **Shapes live in the source, one type per file.** Every file under `src/Alpaca/Models/`, `src/Alpaca/Errors/` and `src/Alpaca/Requests/` declares exactly one public type named after the file, so a type name *is* its path: take it from the operation's Type sources table (or the kind's directory — records `src/Alpaca/Models/`, enums `src/Alpaca/Models/Enums/`, typed errors `src/Alpaca/Errors/`, request records `src/Alpaca/Requests/`, or `src/Alpaca/Requests//` for an operation on a controller; an operation with several tags is filed under the first tag it declares) and read the fields, `required` flags, enum values and, on a model, the `[JsonPropertyName]` wire names from that one file (a request record is never serialized and carries none). The compiler is the backstop: a wrong name fails to build. **This map is how you traverse the SDK.** Do **not** grep, glob, `find`, or otherwise scan the clone to locate an operation, model, enum, union, or error type — the map (and the name-is-path convention) is the locator. Instead: 1. Open `sdk-map.md` and follow its controller table to the operations pages in scope; read every contract fact (signature, error accessors, pagination) by lookup. -2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, the `ct` parameter name); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. +2. **Silence means the default.** The index's defaults table states what holds for every operation (throw-only, no pagination, the default server group, inputs as one request record); an operation's block states only what differs. A block silent on pagination has none — take the default and move on rather than opening the source to confirm it. 3. For a **shape** (model fields, enum values, union accessors, a typed error's payload), open the one declaring file the map names — that is the normal flow here, not a fallback — and read it scoped. -4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` and `` docs in the `Api/{Controller}.cs` file the page header names. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. +4. The **HTTP verb/route and the endpoint's behavioural prose** live on the operation method itself — the XML `` docs in the `Api/{Controller}.cs` file the page header names; what each input means is in the per-member docs on its request record. Read them there when wire-level detail or call-acceptance semantics matter; never reconstruct a route from memory. Keep lookups cheap — the rules that keep a session's context small: -- Collect the contracts for **every** in-scope operation in **one** pass — signatures and error accessors from the map pages, then required fields with wire names and enum values from the declaring files those pages name — into the **contract sheet** in `trader-api-plan.md`, then implement from the sheet. Do not re-open a map page or source file per field, and never re-look-up a fact the sheet already carries. +- Collect the contracts for **every** in-scope operation in **one** pass — signatures and error accessors from the map pages, then required fields with wire names and enum values from the declaring files those pages name — into the **contract sheet** in `alpaca-plan.md`, then implement from the sheet. Do not re-open a map page or source file per field, and never re-look-up a fact the sheet already carries. - When you do open a source file, read it **scoped**: with an offset/limit, or a search for the one symbol — never dump a whole file into the conversation. - Never open the SDK's `api-reference.md` — the map supersedes it. ## SDK source — get it first; the map lives inside it -The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/trader-api-sdk-src/`), reused for the whole session. The clone is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. +The SDK source is where the map lives, so **getting it is the first step of SDK work this session, not a fallback**: one shallow clone at the recorded ref, into the **system temp directory** (`/alpaca-sdk-src/`), reused for the whole session. It is a read-only reference, never in the project repo, and **not** a build dependency. Opening the **one declaring file** a map page names is the normal flow here — that is where model, enum and error shapes live. What stays off-limits is *locating* anything by scanning: every file you open is one the map named, opened scoped, never found by grep. -**Clone once, up front**, into a fresh timestamped folder under `/trader-api-sdk-src/`, and reuse that folder for the rest of your session: +**Clone once, up front**, into a fresh timestamped folder under `/alpaca-sdk-src/`, and reuse that folder for the rest of your session: ```bash # Linux/macOS: -dir="${TMPDIR:-/tmp}/trader-api-sdk-src/$(date +%Y%m%d-%H%M%S)" +dir="${TMPDIR:-/tmp}/alpaca-sdk-src/$(date +%Y%m%d-%H%M%S)" git clone --filter=blob:none --branch main https://github.com/context-plugins/alpaca-csharp-sdk "$dir" # Reuse "$dir" for the rest of your session (it is your clone path). ``` ```powershell # Windows (PowerShell): -$dir = "$env:TEMP\trader-api-sdk-src\$(Get-Date -Format yyyyMMdd-HHmmss)" +$dir = "$env:TEMP\alpaca-sdk-src\$(Get-Date -Format yyyyMMdd-HHmmss)" git clone --filter=blob:none --branch main https://github.com/context-plugins/alpaca-csharp-sdk $dir # Reuse $dir for the rest of your session (it is your clone path). ``` -A branch is a moving target, not a pin: `main` can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the drift; never patch around it from memory. +The source you read is branch `main`: a branch is a moving target, not a pin — it can be regenerated after this plugin ships, and then the map you read describes code the package no longer matches. If a name from the map fails to compile, trust the compiler, re-read the source file the map's row names, and report the mismatch; never patch around it from memory. -The clone lives in `/trader-api-sdk-src/`, **never** in the project repo, and its path never goes into `trader-api-plan.md` — the plan must stay portable, and the clone is a session-local reference, not part of the project. +The clone stays in the system temp directory, and `alpaca-plan.md` never carries its path — the plan must stay portable. -Then confirm the SDK shape **only** from that local clone — not by either of these: +Then confirm the SDK shape **only** from the clone — not by either of these: - **Do not decompile or run reflection over the built assembly.** A compiled assembly drops what the source carries and the other skills rely on: the XML ``/parameter doc-comments, the exact parameter names and order, and each method's internal request-builder list. -- **Do not fetch GitHub files one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Clone once and read locally instead. Only if you truly cannot clone (`git` is unavailable) fetch a **raw** URL, derived from the source repo above — never a `…/blob/…` page. +- **Do not fetch SDK files from the web one at a time** as your way in — `…/blob/…` pages return HTML (not source) and guessed paths fail, which is exactly how ad-hoc fetching breaks. Read the local clone instead; only if you truly cannot clone (`git` is unavailable), fetch a **raw** URL derived from the source repo above — never a `…/blob/…` page. -Layout — where the SDK map's file references resolve (open these directly; do not scan for them): +Layout — where the SDK map's file references resolve, relative to the SDK root in the clone (open these directly; do not scan for them): -- `Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML ``/`` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. -- `Models/` (+ `Models/Enums/`) — request/response records, unions and enums; one public type per file, named after the file. -- `Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). -- `Core/` — HTTP infrastructure (`SdkException`, `RawError`, auth, retries, `SdkHook`). -- `Servers/`, `TraderApiClient.cs`, `ServiceCollectionExtensions.cs` — environments, the client, DI. +- `src/Alpaca/Api/` — one file per controller/group; **this is where the operation methods live** (the HTTP verb and route, and the XML `` docs carrying the endpoint's behavioural prose). The map's per-controller pages name the exact file. +- `src/Alpaca/Models/` (+ `src/Alpaca/Models/Enums/`) — body and response models, unions and enums; one public type per file, named after the file. +- `src/Alpaca/Requests/` — one request record per operation that takes input, carrying the per-member docs for each input; a controller's records sit in `src/Alpaca/Requests//` (an operation with several tags is filed under the first tag it declares). +- `src/Alpaca/Errors/` — per-operation `{Operation}Error` types (only Case-A operations have one; the map's rows say which case each operation is). +- `src/Alpaca/Core/` — HTTP infrastructure (the `SdkException` family, `RawError`, auth, retries, `SdkHook`). +- `src/Alpaca/Servers/`, `src/Alpaca/AlpacaClient.cs`, `src/Alpaca/ServiceCollectionExtensions.cs` — environments, the client, DI. -**Leave the clone in place — do not delete it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. The OS reaps the temp directory on its own; a future session simply makes its own timestamped clone. +**Leave the clone where it sits — do not delete it, copy it or edit it.** It is a read-only reference with nothing of yours in it, and keeping it is what lets every later step in this session reuse it instead of cloning again. An edit made in it never reaches the SDK, so report what needs changing instead of patching it; the OS reaps the temp directory on its own, and a future session simply makes its own timestamped clone. ## Idempotency — the real keys are on the map; the injected header is not one -The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Trader API may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. +The generator injects an `Idempotency-Key: Guid.NewGuid()` header on **every non-GET operation that does not already declare that header itself** — fresh on every call, invisible to you, and **not** an idempotency key in any meaningful sense: a value that changes per call deduplicates nothing, and Alpaca may not document reading that header at all. `dotnet-configuration-resilience` explains why a visible header like this is worse than an absent one. -Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's map row: look for a key-shaped parameter (an idempotency key, a request id) in the signature. Its semantics — retention windows, when it is mandatory — are provider prose, in the method's XML ``/`` docs in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those parameters show in the signatures too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. +Whether an operation takes a **real**, caller-supplied key is an API fact, visible on that operation's request record: look for a key-shaped member (an idempotency key, a request id) in the record file its **Type sources** table names. Its semantics — retention windows, when it is mandatory — are provider prose, in that member's XML docs and the method's `` in the `Api/` file the page names. Some APIs expose **`If-Match` optimistic concurrency** instead — a different guarantee (reject my write if the resource changed) solving a different problem (lost updates, not duplicates); those members show on the request record too. For every write with no key, the answer is reconciliation — `dotnet-configuration-resilience` — not hope. ## Sensitive data — check the request models before you log anything -Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's Type sources table names it) lists every field with its `[JsonPropertyName]` wire name. If the scope touches such a field, three generator facts decide your logging posture (all in `dotnet-configuration-resilience`): JSON request bodies are logged **verbatim** when `LogRequestBody` is on, with no redaction; form bodies are masked only by deny-list; and leaving `options.Logging.LoggerFactory` unset arms the `TRADERAPICLIENT_LOG` environment variable, whose `trace` level forces body logging on with no code change and no deploy. So on any build that can carry such a field: `LogRequestBody` stays off, `LoggerFactory` is set explicitly in production, and your own diagnostics never echo a request body on those paths. +Whether this API's **request** models carry fields you must never log — card or bank numbers, personal data, message content — is a shape question: each request model's declaring file (the map's Type sources table names it) lists every field with its `[JsonPropertyName]` wire name. If the scope touches such a field, three generator facts decide your logging posture (all in `dotnet-configuration-resilience`): JSON request bodies are logged **verbatim** when `LogRequestBody` is on, with no redaction; form bodies are masked only by deny-list; and leaving `options.Logging.LoggerFactory` unset arms the `ALPACACLIENT_LOG` environment variable, whose `trace` level forces body logging on with no code change and no deploy. So on any build that can carry such a field: `LogRequestBody` stays off, `LoggerFactory` is set explicitly in production, and your own diagnostics never echo a request body on those paths. Responses are often safer — many APIs return masked variants of what requests carry raw — but that too is a model-shape fact: check the response model's file before assuming. @@ -126,17 +129,17 @@ On a success the SDK returns only the deserialized body: the HTTP status and hea ## The `dotnet-*` skill names are not unique across plugins -Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. +Every APIMatic .NET plugin ships this same set of `dotnet-*` skill names. Copies from the **same generator version** are byte-identical, so between those plugins a collision is harmless. A copy from a **different generator version** is not — it describes a different runtime, and an older plugin's retry eligibility, request-options surface and unknown-field handling all differ from this SDK's. **Load the copy that ships with THIS plugin**: write plugin-qualified names where your harness supports them, and where it does not, confirm the copy you loaded describes the runtime this SDK actually ships — `RequestOptions` carrying both a log level and `Hooks`, and a `src/Alpaca/Core/Hooks/SdkHook.cs` in the source. The `X-APIMatic-Gen-Version` wire header does not distinguish these surfaces, so it is not the check. ## Integration workflow — load the companion skill at each step You loaded every companion the sheet named before starting (per `dotnet-integrate-alpaca`); before you write the code for each step, re-read the one that governs it — even if you have already read the relevant source. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new TraderApiClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) -2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `TraderApiClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) -3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* call list/search ops with named arguments — many optional params have no C# default and mis-bind in a positional call; and whether a write takes a real idempotency key is on its map row — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) -4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `StringEnum` not C# enums, and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) -5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`SdkException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) +1. **Client & DI setup** — load **dotnet-client-initialization** before you write `new AlpacaClient(...)`, build its options, or DI-register it. (*The signature will not tell you:* the `HttpClient`/handler pipeline must be long-lived and reused via `IHttpClientFactory`, not rebuilt per request; the SDK client wrapper over it may be transient.) +2. **Authentication** — load **dotnet-authentication** before you set credentials. The scheme(s) this SDK accepts are the credentials properties on `AlpacaClientOptions` — the identity table above names them. (*The signature will not tell you:* set credentials before constructing the client or in the DI callback, and load secrets from configuration rather than hardcoding.) +3. **Calling an endpoint / building a request body** — load **dotnet-calling-endpoints** before the first `client.{ApiGroup}.{Operation}(...)` call. (*The signature will not tell you:* every input goes on the operation's request record — `required` members must be set, the rest are optional or already carry the spec's default; and whether a write takes a real idempotency key is on that record — the injected `Idempotency-Key` header is not one, see *Idempotency* above.) +4. **Models** — load **dotnet-models** the moment a request/response field is not a plain string or number. (*The signature will not tell you:* unions are built with factory methods and read via `TryGet…` (no `new`), enums are `OpenStringEnum` / `OpenIntEnum` records with a generated `Match` — not C# enums, and with no public factory — and unknown response fields are kept on any model that carries an extension-data bag — conventionally `AdditionalProperties`, but a schema that closes itself with `additionalProperties: false` has none, and one that already declares a member of that name pushes the bag to a deduplicated spelling. Check the model's own map row rather than assuming the property is there.) +5. **Error handling** — load **dotnet-error-handling** before you write any `try/catch`. (*The signature will not tell you:* an operation is either Case B (`ApiException`, no typed accessors) or Case A (a typed `{Operation}Error`) — how the API mixes them is its own fact, so confirm each operation's case in its map row; and `TryGetRawError` is not a catch-all on the typed errors. Whether no-throw `…Result` variants exist at all is stated once, in the map's defaults table; never assume one does.) 6. **Configuration & resilience** — load **dotnet-configuration-resilience** when you tune retries, timeouts, the base URL, pagination, or logging. (*The signature will not tell you:* `HttpMethodsToRetry` gates **every** retry trigger, so a `POST` is never resent by default — but a `GET` that hangs costs far more than the `Timeout` value suggests, because the per-attempt timeout is itself retried. `Timeout` is per-attempt not total, `RetryOptions.Disabled()` turns retries off, and there **is** a built-in logger on `options.Logging` — whose `LogRequestBody` does not redact JSON.) 7. **Testing** — load **dotnet-testing** before you stub the SDK. (*The signature will not tell you:* the `HttpClient` constructor argument is the test seam; match the project's existing framework and assertion style.) diff --git a/plugins/alpaca/skills/dotnet/dotnet-integrate-alpaca/SKILL.md b/plugins/alpaca/skills/dotnet/dotnet-integrate-alpaca/SKILL.md index 5db72fee0..c78434e7b 100644 --- a/plugins/alpaca/skills/dotnet/dotnet-integrate-alpaca/SKILL.md +++ b/plugins/alpaca/skills/dotnet/dotnet-integrate-alpaca/SKILL.md @@ -1,35 +1,35 @@ --- name: "dotnet-integrate-alpaca" -description: "MANDATORY FIRST STEP for Trader API .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Trader API in C# — AccountActivities, AccountConfigurationsApi, Accounts, CalendarApi, ClockApi, Orders, PortfolioHistoryApi, Positions, Watchlists — or when a Trader API .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file trader-api-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." +description: "MANDATORY FIRST STEP for Alpaca .NET SDK work in a C#/.NET project — load this BEFORE opening the SDK map or touching any project file; .NET/C# SDK ONLY, never load it for any other language. Applies when asked to integrate Alpaca in C# — AccountActivities, AccountConfigurationsApi, Accounts, CalendarApi, ClockApi, Orders, PortfolioHistoryApi, Positions, Watchlists — or when a Alpaca .NET SDK call errors or behaves unexpectedly. It carries four binding rules stated NOWHERE else — (1) for implementation work, the plan file alpaca-plan.md is written at the project repo root BEFORE any project file is created or edited; (2) dotnet-getting-started and the SDK map it carries are loaded and used for every contract fact, never memory; (3) every dotnet-* skill the contract sheet's REQUIRED READING names is loaded before implementation starts; (4) every sheet row carries one of three labels — a map page, UNVERIFIED, or YOUR CALL — not in the map. Skip it and SDK facts get written from stale memory." --- -# Trader API .NET SDK — Workflow (map + skills) +# Alpaca .NET SDK — Workflow (map + skills) -You are the Trader API .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. +You are the Alpaca .NET SDK specialist for this task — one agent for every SDK need: planning, answering contract questions, implementing, and fixing SDK errors. Your scope is **C#/.NET only**: if a request concerns any other language or SDK, say so and stop. Your source of truth is the **SDK map that ships inside the SDK source repo** (`sdk-map.md` plus its `map/operations/` pages; model, enum and error shapes live in the source files those pages name), obtained per `dotnet-getting-started`, plus the companion `dotnet-*` skills for usage traps. Your training data on this SDK is stale — every fact you emit must come from a map page or a map-named source file you actually read this session, in that same clone. You never guess, and you never open the SDK's `api-reference.md`. You do all of it yourself — ground, plan, implement, fix — there is no helper agent to delegate to. -**Scope guard:** the APIMatic-generated Trader API **.NET SDK** (root namespace `TraderApi`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. +**Scope guard:** the APIMatic-generated Alpaca **.NET SDK** (root namespace `Alpaca`) in **C#/.NET projects only**. Unrelated API, or any language other than C#/.NET — do nothing; this skill does not apply. ## Grounding rules -- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. Two rules hold regardless: the clone never leaves the system temp directory, and its path never appears in `trader-api-plan.md` — the plan must stay portable. +- **Map for contracts; the map-named file for shapes.** Follow `dotnet-getting-started`'s *SDK map* and *SDK source* sections in full — they define how to obtain the source (the map lives inside it), that reading a model/enum/error shape means opening the one declaring file the map names (routine, scoped), and why locating anything by grep/glob/`find` over the tree is a defect rather than a shortcut. The clone stays in the system temp directory, and `alpaca-plan.md` never carries its path — the plan must stay portable. - **Never grep, glob or `find` the SDK tree to locate something** — the map is the locator, and a type name is its path. Every lookup starts at the map index and ends on the one page (or the one source file) it names. - **Your contract sheet never leaves a contract fact open for "later."** The map pages and the files they name answer nearly everything — resolve them during planning, not mid-implementation. For the rare in-scope fact even the source cannot settle: if only live traffic could confirm it, convert it into a concrete defensive-coding directive on the sheet and label the uncertainty `UNVERIFIED`. Never leave a row marked for a later source lookup: an open row is how you end up opening source mid-implementation. - **When you judge how far a contract can be trusted**, the trust judgment may cite ONLY evidence visible in the map or SDK source (e.g. two generated definitions that disagree, a suspicious shared model) — never training-data memory of this API, and never claims about what the live wire "usually" sends. -- **The sheet records Trader API's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. +- **The sheet records Alpaca's call surface; the application's design is decided at implementation time.** Its persistence, its concurrency rules, and the request contract its own callers must satisfy are not SDK facts. Where an SDK fact forces an application decision, record the fact and its consequence on the sheet, mark the decision `YOUR CALL — not in the map`, and decide it at implementation time against the task. - **Name only what you have read.** Never name a claim, header or route of the application as though you knew it exists — unless you have read that code. Name configuration by its binding key, never by a raw environment variable, and give the default the map documents where there is one: a setting you invent a name for is a setting no deployment will supply. ## Workflow -**If the user opens with a reported SDK error or unexpected Trader API behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: +**If the user opens with a reported SDK error or unexpected Alpaca behaviour** (not new feature work), go straight to *Step 4 — Fixing SDK errors* below — do not run the plan-first flow for a bug report. Otherwise, for implementation work: -### Step 1 — Plan first: write `trader-api-plan.md` (always, for any implementation work) +### Step 1 — Plan first: write `alpaca-plan.md` (always, for any implementation work) -Your FIRST deliverable is `trader-api-plan.md` at `/trader-api-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. +Your FIRST deliverable is `alpaca-plan.md` at `/alpaca-plan.md` — that exact path, always; never pick another location. It carries the plan and the contract sheet in the format below, and it is the only project-repo file you write before the HARD GATE further down. #### How to ground (map-first, one pass) 1. Load `dotnet-getting-started`; open `sdk-map.md` (the index). -2. From the index, open the **operations pages** for every controller in scope — take signatures (parameter order + types, nullables that must be passed), return types, error case (A: typed `SdkException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `SdkException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML ``/`` docs in the `Api/` file the page names: read it there. +2. From the index, open the **operations pages** for every controller in scope — take signatures (the request record each operation takes, and its required members), return types, error case (A: typed `ApiException<{Operation}Error>` with its `TryGet…` accessors and payload type / B: `ApiException`), and pagination — remembering that a block silent on a defaults-table point means the default applies. Where an operation's *semantics* decide what you must pass, that prose is the method's XML `` in the `Api/` file the page names, and the per-member docs on its request record: read it there. 3. Open, scoped, the **declaring source file** for every request/response model you will reference — the operation's **Type sources** table names it — and take field names WITH their `[JsonPropertyName]` wire names, `required` flags, nullability, and the envelope shape. Enum value lists and union accessors come from their declaring files the same way. 4. Identify which companion skill governs each step in scope. For each, write the trap note as a **named hazard plus a `MUST load` pointer** — *not* as the resolved answer. Naming the hazard ("what `Timeout` actually bounds") is right; resolving it inline ("`Timeout` is per-attempt") is wrong — a resolved trap gives you no reason to load the skill, and the skill carries the parts a one-line note cannot. **Never restate a companion skill's default or semantics in a trap note — not even when you believe it correct.** Write the *consequence*, not the answer. Contract *facts* are the opposite: those you resolve fully and inline. 5. Collect everything in ONE pass — the whole point is that you never have to rediscover a contract mid-coding. @@ -38,7 +38,7 @@ Your FIRST deliverable is `trader-api-plan.md` at `/trader-ap These need no SDK knowledge and touch no project file, so do them before or alongside the map work: -- obtaining the SDK source at the recorded ref (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); +- reaching the SDK source (per `dotnet-getting-started` — the map lives inside it, so this comes before any map work); - the repo survey (read-only exploration of conventions and layering) — record each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; - `dotnet restore` and a baseline `dotnet build` / `dotnet test` of the UNTOUCHED solution, so later failures are attributable to your changes; - locating the SDK reference and its version in the project; @@ -49,15 +49,28 @@ Never use this prerequisite work as a "head start" on implementation: **creating #### HARD GATE -**No project-file creation or edits until `trader-api-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. +**No project-file creation or edits until `alpaca-plan.md` exists at that path with every section below filled in.** Starting to code before the sheet exists defeats the plan-first design: the sheet is what keeps SDK facts out of your memory and in the map. The gate bars coding before the sheet exists; it does not bar the read-only prerequisite work above. -#### The `trader-api-plan.md` format (keep it tight — tables, not prose) +#### The `alpaca-plan.md` format (keep it tight — tables, not prose) 1. **Scope & sequence** — the implementation steps in order, each naming the operations it uses. A capability the map lacks is a Blocker (§6), never a data path you invent to replace it. -2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim, every parameter name the literal C# identifier (the cancellation-token parameter really is named `ct`, so named arguments write `ct:`); and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. +2. **CONTRACT SHEET** — open the section with two literal warning lines: that signatures are generated code, verbatim — each operation that takes input takes ONE request record as its first parameter (an operation with no inputs takes none), built with an object initializer whose property names are the record's own, never flat arguments; and that every SDK type is written fully-qualified with the namespace its source path implies, taken from the path the map gives for THAT type, never from where a neighbouring type sits. Then one table row per operation: controller property · method signature · request record + its members (`Name: type, required?`) · body model + its fields (`Name (wire_name): type, required?`) · response envelope + the inner fields the integration reads · error case A/B + accessors + payload type · pagination · **source** (§7). Below it: the enum value tables actually needed, and the client construction/auth/server-node facts. ⚠ A request model may mark nothing required, and then `required?` selects nothing for you — carry the optional fields the endpoint's own prose ties to whether the call is accepted, and say which doc-named fields you left out. + +The CONTRACT SHEET also carries a **CROSS-OPERATION INVARIANTS** table, directly below its per-operation rows: the rules no single row can state, because they relate two operations. One row per value the task lets a caller supply where a *different* operation defines the legal set for it (identifiers, handles, codes, references); `none` if the task has no such value — never leave the table out. + +| Invariant | Operations | Enforced where | +| --- | --- | --- | +| a value accepted by X must be one Y returned | `{WriteOperation}` ← `{ListOperation}` | before `{WriteOperation}` is called | + +⚠⚠ The map never states these rules; derive them from the task. + +⚠⚠ **Operations** names the operation that *defines* the legal set: the list or search call that returns what this application offers, or the earlier call whose result this application stored. A lookup that confirms a single value exists is not an answer: it proves the value exists at the provider, not that it is one this application offers. + +**Enforced where** names the member that checks, before the write, that the caller's value is in that set. If the application never reads that set, say so there and add the read — the check is part of the work. `YOUR CALL — not in the map` is not a legal entry in any cell of this table. + 3. **Trap notes** — one line per hazard, attached to the step where it bites, each ending in an inline **`MUST load `** pointer. Name the hazard and its consequence; do not resolve it. ⚠⚠ **"Do not resolve it" is the load-bearing half of this rule.** A trap note that answers its own question hands you a usable one-liner — and holding one, you do not open the skill. You implement from the sentence, and everything the skill carries beyond it never reaches the code. So: state the hazard, state what it costs, hand over the skill, and stop. No fix, no snippet, not even a partial answer. A trap note from which you could write correct code without loading the named skill is a defect. -4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, both of these hazard rows, because `System.Text.Json.JsonException` reaches the boundary from two directions and they need opposite handling: a drifted or malformed **2xx** body (a missing `required` member) surfaces as a `JsonException` from deserialization, **not** as an `SdkException`, so an SDK-exception-only catch ladder lets it escape; and a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape throws `JsonException` *while the error object is being constructed*, so it **replaces** the `SdkException` and the HTTP status is destroyed with it. -5. **PRODUCTION READINESS** — the fixed eight-row table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. +4. **REQUIRED READING** — close the sheet with the de-duplicated list of every `dotnet-*` skill named above, one line each: skill · the step it governs. Write each name so the copy is unambiguous — every APIMatic .NET plugin ships these same skill names, and a bare name gives you no way to tell which one you loaded. State that these are to be loaded **before implementation starts**, and that the sheet deliberately does not carry their contents. This block is mandatory even when the trap notes are few — an integration always writes an error boundary, so `dotnet-error-handling` always appears here. Always include, verbatim, this hazard row: a body that does not match its declared type — a drifted or malformed **2xx** response (a missing `required` member) or a **non-2xx** body that does not match its operation's generated `{Operation}Error` shape — surfaces as `ResponseDeserializationException`, an `ApiException` that keeps the HTTP status and names the target type but is **not** an `ApiException`; a catch ladder that handles only `ApiException` lets it escape, so it must also catch `ResponseDeserializationException` (or `ApiException`). +5. **PRODUCTION READINESS** — the fixed table below, every row carrying a *decision*. Naming a skill in REQUIRED READING does not address a concern; it defers it. `N/A` is a legitimate answer where it is genuinely true, but it must carry its reason. 6. **Assumptions & Blockers** — anything you had to assume about the user's intent, and anything that blocks planning. An empty section is a valid outcome; an invented fact is not. If you expect the provider to reject a call the plan makes, that is a **Blocker**. 7. Every row's **source** cell cites its map page or the map-named declaring file, so a later lookup is one targeted open, not a search. A row with neither to cite is not a contract fact: write `YOUR CALL — not in the map` there instead. Three labels, one order — a fact the map settles cites that page or file; a fact only live traffic can settle is `UNVERIFIED`; a decision about the application is `YOUR CALL — not in the map`. Something that stops you planning goes in §6. @@ -67,12 +80,46 @@ Never use this prerequisite work as a "head start" on implementation: **creating | 2 | **Secret sourcing & rotation** | Where the secret comes from, and that the DI registration builds the options object **once at registration** and captures it in the singleton — so a rotated secret does not take effect until the process restarts. If rotation without a restart is required, say how. | | 3 | **Total timeout budget** | The number the caller actually gets, not the knob. `Timeout` is **per attempt**, so a hung retryable call costs a multiple of it. State the budget and where it is enforced — a `CancellationToken` deadline is the only thing that bounds a whole call. | | 4 | **Write-retry ownership** | Which of the scope's writes the SDK may resend and which it may not. The default `HttpMethodsToRetry` covers `GET, HEAD, PUT, OPTIONS`, so `POST`/`PATCH`/`DELETE` are never resent by the SDK — and `PUT` **is**. | -| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its parameter name — is on that operation's map row. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | +| 5 | **Idempotency & ambiguous writes** | For each write in scope: the key it uses, or that none exists. Whether an operation takes a REAL caller-supplied key — and its member name — is on that operation's request record: the record file its map row's **Type sources** table names. The generator-injected `Idempotency-Key` header (`Guid.NewGuid()`, fresh on every call) is **not** a key and must not be cited as one. Where no key exists, record the reconciliation path instead. | | 6 | **Observability** | What is logged at which level, that **JSON request bodies are logged unredacted** when `LogRequestBody` is on, and which correlation id from the provider's error bodies reaches your own logs. | | 7 | **Sensitive data** | Whether the scope carries data you would not want in a log — each request model's declaring file lists every field in scope. If it does: `LogRequestBody` stays off **and** `LoggerFactory` is assigned explicitly, so the SDK's log environment variable cannot switch it on from outside the code (see `dotnet-getting-started`). Form bodies are masked only by deny-list, so a key you have not listed prints in the clear. | | 8 | **Environment selection** | Which base URLs each deployment talks to. Enumerate the server groups and environment members from the map's *Servers & auth* section — there may be more than one group, and each operation resolves through its own. State which groups the scope touches and what each deployment sets; if the SDK declares no sandbox environment, say how test traffic is kept away from the live system. | +| 9 | **Duplicate prevention under concurrency** | For each write a caller can trigger twice: its row in **DUPLICATE CLAIMS** below. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in §6, not a row. | +| 10 | **Partial results** | For each paged read that can stop at a cap: the field or return type that tells the caller the result was cut short. | +| 11 | **Unknown outcomes** | For each write whose connection can fail after the provider may have acted: how the code settles the outcome instead of reporting a failure. | + +A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7, 8, 9 and 11 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write. + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces: a primary key is refused even by in-memory test stores; a unique index may not be. + +**Where that rejection is caught**: the `catch` for what the store raises on the second claim — a lookup that returns the earlier result is not a catch. + +Write `TBD` in **Where in the code** while you plan — every row. Once the code compiles, replace each `TBD` individually with two members, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a member that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. + +**PAGED READS** — one row per read that walks pages; `none` if the scope reads no paged list. + +| Read | What caps it | How the caller learns the answer was cut short | Where in the code | +| --- | --- | --- | --- | + +⚠⚠ A log line is not a legal entry in the third column: whoever reads the result never reads your logs. Name the field or return type that changes. Write `TBD` in **Where in the code** while you plan; once the code compiles, replace it with the member that sets that field or return type. A `TBD` left behind is not addressed. + +**UNKNOWN OUTCOMES** — one row per write whose connection can fail after the provider may already have acted; `none` if the scope makes no writes. + +| Write | The operation you re-read with | The reference you search by | Where in the code | The test that fails the connection | +| --- | --- | --- | --- | --- | + +⚠⚠ Reporting a failure is not a legal entry, and neither is leaving the caller to retry. Settle the outcome in the failing write's own `catch`, before it returns, or record it as unknown for code that settles it later — a report someone reads is not that code. With no reference, the third column names what the match uses; if the provider offers no way to find the write, that is a Blocker in §6, not a row. -A reviewer grades that table alone: each row either records a decision or records why it does not apply. A row that points at a skill, restates the concern, or sits blank is **not addressed**. Rows 5, 7 and 8 are the ones where "not addressed" costs something that cannot be recovered afterwards — a duplicate charge or duplicate write, sensitive data in a log, or test traffic sent to a live system. +**Where in the code**: that `catch`, or the unknown state and the code that settles it — a re-read helper nothing calls is not addressed. **The test that fails the connection** asserts what follows. Both are `TBD` while you plan, replaced once the code compiles. A `TBD` left behind is not addressed. Keep the file lean: no copied map pages, no full model dumps, and no clone path — only the operations and fields the scope actually touches. @@ -84,42 +131,43 @@ These are API-agnostic usage skills; loading them is not the same as reading the Before implementing, check the plan's **Assumptions & Blockers** section: -- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `trader-api-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. +- Blocker or major assumption → surface it to the user in plain language, get their answer, then revise `alpaca-plan.md` in place with targeted edits — edit the changed rows, append the new section. Rewriting the whole file to change a few rows is a defect. - Minor assumptions only → proceed. Full re-planning only on genuine scope change; for a single missing fact mid-implementation, look it up in the map — never guess. ### Step 3 — Implement from the contract sheet -1. Read `trader-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. +1. Read `alpaca-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. A row whose source column says `YOUR CALL — not in the map` is the one exception: that row is a planning-time judgment about YOUR application, so weigh it against the task and follow the task. 2. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 2 — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire names, error accessors, enum values) from the contract sheet or a map lookup — never re-derive one from a companion. 3. After every change: `dotnet build`; fix non-SDK errors yourself. -4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `TraderApi.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. -5. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. +4. **Any compile or runtime error involving an SDK type or member** (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `Alpaca.*`, or a provider error at runtime) → *Step 4* below. Do not attempt more than one self-fix of an SDK-name error before switching to that procedure — rewriting from the same knowledge that produced the error is guessing. +5. Once the code is complete, open each member the **DUPLICATE CLAIMS** and **UNKNOWN OUTCOMES** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +6. Run the project's tests (`dotnet test`); verify the integration end to end the way the task demands. ### Step 4 — Fixing SDK errors (map-first, in place) -A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `TraderApi.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. +A compile or runtime error involving an SDK type or member (`CS1061`, `CS0117`, `CS0234`, `CS0104`, `CS1503`, `CS7036`, … on `Alpaca.*`, or a provider error). Resolve it map-first — never patch it by guessing. Load `dotnet-getting-started` first if you have not already — the map is where every fix below comes from. -1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum or error symbol, open the declaring file — its name is its path (`Models/{Type}.cs`, `Models/Enums/{Type}.cs`, `Errors/{Type}.cs`), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. -2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its ``/`` docs and the request it builds — in this session's SDK clone, scoped. Never scan the tree. +1. **Map lookup first.** For an operation symbol, find its block on the controller's operations page; for a model, enum, error or request-record symbol, open the declaring file — its name is its path (`src/Alpaca/Models/{Type}.cs`, `src/Alpaca/Models/Enums/{Type}.cs`, `src/Alpaca/Errors/{Type}.cs`, `src/Alpaca/Requests/{Type}.cs`, or `src/Alpaca/Requests/{Controller}/{Type}.cs` for an operation on a controller — an operation with several tags is filed under the first tag it declares), or take it from the operation's Type sources table. If the code contradicts what you read (wrong field name, missed response envelope, wrong param order, wrong namespace), that is the fix. Response envelopes are the classic case: response types often wrap their payload in one field — reads go one level down. +2. **Go one level deeper on a real gap.** If what you read matches the code, or ambiguity remains, open the operation's own method in the `Api/{Controller}.cs` file the page header names — its `` docs and the request it builds — and the per-member docs on its request record, in this session's SDK clone, scoped. Never scan the tree. 3. **Never re-guess.** Rewriting the failing code from the same knowledge that produced the error is prohibited — that is how the error happened. Each failing symbol gets a map/source-grounded answer before its line changes; never mutate payloads, field names, or status handling speculatively to "see if it works". 4. **Fix in place** — edit only the project files involved in the error, grounded in the map row (or the named source file). 5. **Build to verify, and classify the outcome:** compiles clean → run `dotnet test` on the touched tests when they exist; compile error → the fix is not done, resolve it the same map-first way and rebuild; build blocked (output locked) → the solution is running, so stop it if you started it, or ask the user to rather than killing their process. 6. **Runtime / provider errors** — read the provider's error payload through the documented path: the operation's error case and `TryGet…` accessors from its map row; `dotnet-error-handling` for the Case A/B mechanics. Config-shaped failures (401, wrong host, timeouts): check auth, the server-node/base-URL configuration, and retry semantics before touching call sites. -If a fix corrects a row in `trader-api-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. +If a fix corrects a row in `alpaca-plan.md`, correct the row in the file too — the sheet stays the record of what was verified. ### Step 5 — Pure questions -A standalone Trader API question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. +A standalone Alpaca question with no code change: load `dotnet-getting-started`, look it up in the map (open the one source file the map names only on a real gap), and answer with the map page (or source file) it came from. No plan file — just the grounded answers; when several questions arrive batched, answer them all in one pass. Never answer from memory, even for "easy" questions. ## Anti-patterns — never do these -- **Never write a Trader API/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** +- **Never write a Alpaca/SDK fact from memory** — every signature, field name, enum value, and error type in your code must come from the contract sheet or a map lookup. And **never write a call from memory "to fix later".** - **Do not re-derive or double-check a sheet row from memory** — re-open the one map page it cites. - **Do not re-derive a contract fact from a `dotnet-*` companion** — they are usage guidance; facts come from the map. - **Do not grep, glob or `find` the SDK tree, and do not open its `api-reference.md`** — the map is the locator; `dotnet-getting-started` says why. -- **Do not web-search Trader API for an implementation detail** — the map and the pinned source are the ground truth for THIS SDK version, and `TraderApi` is what the generated source declares, not what public docs describe. +- **Do not web-search Alpaca for an implementation detail** — the map and the SDK source it lives in are the ground truth for THIS SDK version, and `Alpaca` is what the generated source declares, not what public docs describe. - **Do not resolve a trap note inline on the sheet** — name the hazard and the skill. diff --git a/plugins/alpaca/skills/dotnet/dotnet-models/SKILL.md b/plugins/alpaca/skills/dotnet/dotnet-models/SKILL.md index 960635a13..c426a34bd 100644 --- a/plugins/alpaca/skills/dotnet/dotnet-models/SKILL.md +++ b/plugins/alpaca/skills/dotnet/dotnet-models/SKILL.md @@ -83,8 +83,8 @@ depending on which the definition declared — two members, one CLR type, and th them apart. Where two variants would collide outright the generator suffixes them (`String`, `String2`). The primitive rows apply to `AnyOf` only, since a `OneOf` has no primitive variants. **Take the exact member name from the contract sheet** (grounded from the SDK map/source) rather than deriving it from -the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; `FromValue` -belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and +the C# type you see. (Unions use the per-variant factories and `TryGet…` readers shown above; +`TryGetKnownValue` belongs to enums.) The `Optional` backing a union is internal — interact only through the factories and `TryGet…`. ## Collections @@ -103,6 +103,10 @@ var body = new {RequestType} A null collection is omitted from the JSON; an **empty** collection is serialized. +**An untyped field holds a `JsonElement`.** A property typed `object` or `JsonElement` (nullable or not) holds a +`JsonElement` after a read, when it is not `null`. Read it with the `JsonElement` API, +never a cast to `Dictionary`. + ## Dates & numbers - **A date/time field is not necessarily a `DateTimeOffset?` — check the property type.** The generator @@ -155,12 +159,31 @@ an error path; it is usually not worth it on values your own code produced. ## Enums -Enums are type-safe string-enums (`StringEnum`) or int-enums (`IntEnum`): use the static constants, -or `FromValue(...)` for a value not known at compile time; they convert implicitly to their underlying -value. Reading back: `.Value` (or the implicit conversion) yields the raw wire -value — but **not** `ToString()`, see the warning below — and the enum types are `record`s, so `==` -compares by value — `{EnumType}.FromValue("x")` equals the -`x` constant. Guard unknown values with `TryGetKnownValue(...)` or `instance.IsKnownValue()`. +Enums are type-safe records — `OpenStringEnum` over `string` or `OpenIntEnum` over `long` — never C# +enums. Use the static constants; they convert implicitly to their underlying value. Reading back: `.Value` +(or the implicit conversion) yields the raw wire value — but **not** `ToString()`, see the warning below — +and `==` compares by value, so two instances read from the same wire value are equal. + +There is **no public factory**: `{EnumType}.FromValue(...)` does not exist, and a value the spec does not +declare cannot be constructed in code. Resolve a raw value with `{EnumType}.TryGetKnownValue(value, out var +known)` — exact, case-sensitive — and guard a received value with `instance.IsKnownValue()`. A server value +the SDK does not declare still deserializes (enums are open on reads), round-trips unchanged with the +server's own casing, and compares without constructing one: `instance.Is("raw")` or `instance == "raw"`. + +Branch with the generated `Match`, not a `switch`: one `on{Member}` arm per declared value in spec order, +then `otherwise`, which receives the raw wire value of anything undeclared. Pass the arms by name — the +parameters are positional, and a regenerated SDK that adds or reorders a value changes the signature (a +source break, and `MissingMethodException` for an assembly compiled against the old shape). + +```csharp +{request}.{EnumProp} = {EnumType}.SomeConstant; +string wire = {response}.{EnumProp}.Value; // raw wire value back out +if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } +var text = {response}.{EnumProp}.Match( + onSomeConstant: () => "some", + onOtherConstant: () => "other", + otherwise: raw => $"undeclared: {raw}"); +``` ⚠ **`ToString()` does not give you the wire value — it gives the record's debug form.** The base class overrides `ToString()` to return the value, but each generated enum is itself a `record`, so the compiler @@ -179,40 +202,28 @@ on a **string** enum (the implicit conversion to `string` wins overload resoluti enum: there is no `string + int` operator, so it binds `string + object` and you get the debug form again. Use `.Value` explicitly whenever the string leaves your process, and the distinction stops mattering. -**The known-value lookup is case-insensitive**, which is easy to rely on by accident. String enums build -their constant table with `StringComparer.OrdinalIgnoreCase`, so `FromValue("captured")` returns the -`CAPTURED` constant itself — same instance, `IsKnownValue()` true, and the **declared** casing is what goes -on the wire. A casing slip in a configuration value is therefore silently corrected. What is *not* -corrected is a value matching no constant at all: that is passed through verbatim, serialized verbatim, and -`IsKnownValue()` returns false — so `IsKnownValue()` is the check that catches a typo, not a case mismatch. -Int enums match exactly; only string enums are case-insensitive. - -⚠ **`FromValue` is emitted per enum, not guaranteed on all of them.** Some generated enums — server / -environment selectors are the ones to watch — expose only their static constants and keep the conversion -helper `protected`, so `{EnumType}.FromValue(someString)` does not compile. Check the type before you plan -to map a configuration string through it; where it is absent, write the string→constant mapping yourself -and default deliberately rather than reaching for a helper that isn't there. - -```csharp -{request}.{EnumProp} = {EnumType}.SomeConstant; -{request}.{EnumProp} = {EnumType}.FromValue(serverProvidedValue); // tolerates unknown values -string wire = {response}.{EnumProp}.Value; // raw wire value back out -if ({EnumType}.TryGetKnownValue(value, out var known)) { /* known constant */ } -``` +A member whose name would collide with the enum's own name, with the inherited or generated surface +(such as `Value`, `Match` or `IsKnownValue`), or with a member of `object` carries a `Member` suffix — a +value `value` becomes `ValueMember`; its siblings keep their plain names. See [reference.md](reference.md) for full string- and int-enum declarations and union-member discovery. ## Unknown / future fields -Models declare their wire properties explicitly, and **every generated model also carries a -`[JsonExtensionData]` property** — `public AdditionalProperties AdditionalProperties { get; init; } = [];` +Models declare their wire properties explicitly. An object or allOf model also carries a +`[JsonExtensionData]` bag — `public AdditionalProperties AdditionalProperties { get; init; } = [];` (`{RootNamespace}.Core.Models`) — so unknown JSON fields are **kept**, not dropped: a field the provider -added after this SDK was generated survives deserialization and round-trips back out on serialize. +added after this SDK was generated survives deserialization and round-trips back out on serialize. A +schema with `additionalProperties: false` has no bag, a `OneOf`/`AnyOf` union has none of its own, and the +bag is named `AdditionalProperties2` when a spec property's C# name is already `AdditionalProperties`. +Take the bag's name and type from the contract sheet. Read one with `response.AdditionalProperties.TryGetValue("field_name", out var value)` (deserializes the captured `JsonElement` to `T`), or `TryGetElement` for the raw `JsonElement`; `Set(key, value)` adds -one to a request. Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the +one to a request. When the schema types its extra fields, the bag is `AdditionalProperties`, whose +`TryGetValue("field_name", out var value)` takes no type argument. +Two cautions: the keys are **wire names** (there is no C# property, so nothing maps the casing for you), and a field appearing here rather than as a typed property is a regeneration signal — prefer regenerating the SDK over building program logic on stringly-typed lookups. The -`AdditionalProperties` property itself has no `[JsonPropertyName]` and no wire name — its contents merge +bag itself has no `[JsonPropertyName]` and no wire name — its contents merge into the model's own JSON object. diff --git a/plugins/alpaca/skills/dotnet/dotnet-models/reference.md b/plugins/alpaca/skills/dotnet/dotnet-models/reference.md index 6c06ee12d..e780a4e68 100644 --- a/plugins/alpaca/skills/dotnet/dotnet-models/reference.md +++ b/plugins/alpaca/skills/dotnet/dotnet-models/reference.md @@ -16,59 +16,70 @@ directly and let the SDK serialize it; for formatting in your own code use the B ## String-enums -Schema enums under `Models/Enums/` are emitted `sealed`; the server-environment enum under `Servers/` is -not, so do not treat `sealed` as part of the contract. +Every enum — schema enums under `Models/Enums/` and the server-environment enum under `Servers/` — is a +`sealed record` with a `private` constructor and a get-only `Value`. There is no public factory. ```csharp [JsonConverter(typeof(StringEnumConverter<{EnumType}>))] -public sealed record {EnumType} : StringEnum<{EnumType}> +public sealed record {EnumType} : OpenStringEnum<{EnumType}> { - public static readonly {EnumType} FirstValue = new("first_value"); + private {EnumType}(string value) : base(value) + { + } + + public static readonly {EnumType} FirstValue = new("first_value"); + public static readonly {EnumType} SecondValue = new("second_value"); - private {EnumType}(string value) : base(value) { } // required — the base has no parameterless ctor + public TResult Match(Func onFirstValue, Func onSecondValue, Func otherwise) => … - // Emitted for most, but NOT all, generated enums — see the note below. - public static {EnumType} FromValue(string value) => FromValueCore(value); + public void Match(Action onFirstValue, Action onSecondValue, Action otherwise) { … } } ``` -> **Check that `FromValue` is actually there before you rely on it.** The base helper it forwards to -> (`FromValueCore`) is `protected`, and some generated enums — notably server / environment selectors — -> ship only their static constants without the public wrapper. On those, `{EnumType}.FromValue(s)` is a -> compile error and you map the string to a constant yourself. - Usage: ```csharp -var v = {EnumType}.FirstValue; // known constant -var u = {EnumType}.FromValue("new_value"); // unknown-tolerant -var c = {EnumType}.FromValue("FIRST_VALUE"); // case-insensitive: returns the FirstValue constant -string raw = v; // implicit conversion to string -if ({EnumType}.TryGetKnownValue("first_value", out var known)) { /* known == FirstValue */ } -var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var v = {EnumType}.FirstValue; // known constant +string raw = v; // implicit conversion to string +if ({EnumType}.TryGetKnownValue("first_value", out var known)) { } // exact match — "FIRST_VALUE" is not found +var all = {EnumType}.GetKnownValues(); // IReadOnlyCollection<{EnumType}> +var label = received.Match(onFirstValue: () => "1", onSecondValue: () => "2", otherwise: raw => raw); +bool same = received.Is("first_value"); // compare a raw value without constructing one ``` +A server value the SDK does not declare deserializes into an instance whose `IsKnownValue()` is false, +keeps the server's own casing, and serializes back unchanged. The server-environment enum is *closed*: an +undeclared value fails the read with `JsonException`, and its `Match` is `internal` with no `otherwise`. + ## Int-enums -Same pattern over `int`: +Same pattern over `long`: ```csharp [JsonConverter(typeof(IntEnumConverter<{EnumType}>))] -public sealed record {EnumType} : IntEnum<{EnumType}> +public sealed record {EnumType} : OpenIntEnum<{EnumType}> { - public static readonly {EnumType} Off = new(0); - public static readonly {EnumType} On = new(1); + private {EnumType}(long value) : base(value) + { + } - private {EnumType}(int value) : base(value) { } // required — the base has no parameterless ctor + public static readonly {EnumType} Off = new(0L); - public static {EnumType} FromValue(int value) => FromValueCore(value); + public static readonly {EnumType} On = new(1L); + + public TResult Match(Func onOff, Func onOn, Func otherwise) => … + + public void Match(Action onOff, Action onOn, Action otherwise) { … } } {request}.{EnumProp} = {EnumType}.On; -int n = {EnumType}.On; // implicit conversion to int +long n = {EnumType}.On; // implicit conversion to long ``` +Member names come from the spec's `x-enum-varnames` when it declares them, otherwise `Value{n}` / +`Negative{n}` from the value itself. + ## Union types — finding the exact members For a `OneOf`/`AnyOf` type, the contract sheet lists the exact members (grounded from the SDK map/source). Each variant `{V}` @@ -85,12 +96,14 @@ one after construction. ## Notes -- **Only optional properties with no default** carry `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`; leaving one - unset omits it from the request JSON entirely (distinct from sending an explicit `null`). An optional - property that has a **default value** (`public bool? Flag { get; init; } = false;`) gets no `JsonIgnore`, - so it is **always serialized** — a body you never touched still sends every defaulted field. Check the - property before assuming "unset" means "absent from the payload"; the difference matters on a PATCH. -- Every generated model ends with `[JsonExtensionData] public AdditionalProperties AdditionalProperties +- **Every optional property** carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, whether or + not it declares a default (`public bool? Flag { get; init; } = false;`): a null value is omitted from the request + JSON entirely, never sent as an explicit `null`. Two shapes have no guard and always reach the wire: a + `required` property, which writes `null` when its type is nullable (`public required string? Reason`), and a + non-optional property with a default (`public long Priority { get; init; } = 1L;`), whose type cannot hold + null. There is no way to send an explicit `null` for an optional property; the difference matters on a PATCH. +- Unless its schema sets `additionalProperties: false`, an object or allOf model ends with a + `[JsonExtensionData]` bag, usually `public AdditionalProperties AdditionalProperties { get; init; } = [];` — unknown response fields are captured there (keyed by **wire name**) and round-trip on serialize. See the SKILL's *Unknown / future fields* for the read/write API and the cautions. On the older 4.0.0 surface this property does not exist and unknown fields are dropped — diff --git a/plugins/alpaca/skills/dotnet/dotnet-testing/SKILL.md b/plugins/alpaca/skills/dotnet/dotnet-testing/SKILL.md index 229002f41..720a21ebc 100644 --- a/plugins/alpaca/skills/dotnet/dotnet-testing/SKILL.md +++ b/plugins/alpaca/skills/dotnet/dotnet-testing/SKILL.md @@ -69,7 +69,7 @@ public async Task ReturnsDeserializedBody() { var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": { "id": 123 } }"""); - var response = await client.{ApiGroup}.{Operation}(/* args */, ct: default); + var response = await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(123, response.{Resource}?.Id); } @@ -77,14 +77,17 @@ public async Task ReturnsDeserializedBody() ## Test an error path -Endpoint methods throw `SdkException` on non-2xx (see `dotnet-error-handling`). `TError` is the +Endpoint methods throw `ApiException` on non-2xx (see `dotnet-error-handling`). `TError` is the operation's `{Operation}Error` model (**Case A**) for operations that have a generated `{Operation}Error` type, or `RawError` **directly** (**Case B**) otherwise — so assert the type that matches your operation. +Whichever the case, the exception itself carries `StatusCode`, `Headers` and `ContentType` (from +`ApiException`) plus the failed call's `Method` and `RequestUri` (from the `SdkException` root), and its +`Message` is the call and the status — `"POST https://host/path returned 422 (UnprocessableEntity)."`. **Case A — typed `{Operation}Error`:** ```csharp -using {RootNamespace}.Core.Exceptions; // SdkException +using {RootNamespace}.Core.Exceptions; // ApiException using {RootNamespace}.Errors; // {Operation}Error types [Fact] @@ -92,9 +95,10 @@ public async Task ThrowsOnApiError() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); + var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // the status is on the exception // ex.Error is the typed ApiError. For a status the operation maps to a typed body (e.g. 422), assert the // typed accessor — its name embeds the body type; the contract sheet lists the exact accessor name. // TryGetRawError is FALSE for those statuses, so don't assert through it here: @@ -105,20 +109,36 @@ public async Task ThrowsOnApiError() } ``` -**Case B — `SdkException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* +**Case B — `ApiException`** (e.g. read/list/find/archive/delete operations). Here `ex.Error` *is* the `RawError` — there is no `TryGet*` / `TryGetRawError`; read it directly: ```csharp using {RootNamespace}.Core.Exceptions; using {RootNamespace}.Core.ErrorResponse; -var ex = await Assert.ThrowsAsync>( - () => client.{ApiGroup}.{Operation}(/* args */, ct: default)); +var ex = await Assert.ThrowsAsync>( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); -Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Error.StatusCode); +Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode); // same value as ex.Error.StatusCode // You can also assert the deserialized error body: ex.Error.ReadAsString() / ex.Error.ReadAsJson(). ``` +**A body that does not match the model is a third outcome.** Stub a 2xx whose JSON does not fit the +response type — or an error status whose body does not fit the `{Operation}Error` template — and the call +throws `ResponseDeserializationException`: an `ApiException` (so `StatusCode` tells you which of the two it +was) but **not** an `ApiException`, carrying the `TargetType` it tried to build and the +`JsonException` as `InnerException`. Lock this down for any operation whose provider is known to drift: + +```csharp +var client = ClientReturning(HttpStatusCode.OK, """{ "{resource}": "not-an-object" }"""); + +var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + +Assert.Equal(HttpStatusCode.OK, ex.StatusCode); +Assert.Equal(typeof({ReturnType}), ex.TargetType); +``` + ## Test the result-style (`ApiResult`) variant If the operation exposes the optional non-throwing `{Operation}Result` sibling (see `dotnet-error-handling`), @@ -135,7 +155,7 @@ public async Task ResultVariantReportsFailureWithoutThrowing() { var client = ClientReturning(HttpStatusCode.UnprocessableEntity, """{ "errors": ["bad input"] }"""); - var result = await client.{ApiGroup}.{Operation}Result(/* args */, ct: default); + var result = await client.{ApiGroup}.{Operation}Result(request, cancellationToken: default); Assert.False(result.TryGetResponse(out _)); Assert.True(result.TryGetError(out var error)); // 'error' is the same TError as the throwing path @@ -144,6 +164,85 @@ public async Task ResultVariantReportsFailureWithoutThrowing() } ``` +## Test a client with OAuth credentials + +With OAuth credentials set, the first request your stub sees is the token `POST`. `ClientReturning` answers +every request alike, so give `StubHandler` a responder that returns queued responses, a token response such as +`{ "access_token": "t", "token_type": "Bearer" }` first. The token is cached, so two calls make three +requests. Or keep the credentials set and give `options.{Scheme}TokenStrategy` a stub that returns +`new OAuthToken { AccessToken = "t", TokenType = "Bearer" }` (for authorization code, an +`OAuthTokenRefreshable`, with `TryRefreshToken` returning `null`), because a strategy without credentials is +never used. Either way, an authorization-code client also needs a `PromptForAuthorizationCode` that returns a +fixed code. +For a bad-credentials test, answer the token request with a 4xx and assert `AuthSchemeException`. + +## Fake the clock + +The SDK reads time through exactly one seam — `{Api}ClientOptions.TimeProvider` (default +`TimeProvider.System`). Retry backoff, `Retry-After`, the per-attempt timeout, the SSE idle timeout, OAuth2 +token expiry and the logged request durations all follow it, so one fake provider drives them together and +a retry test no longer sleeps through `1s + 2s + 4s` of real backoff. Use a provider that **implements +timers** — `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` — because the retry delay +and the idle window are timers on the provider: a `GetUtcNow`-only fake throws when the SDK asks it for a +timer, which is deliberate and better than a silent real sleep. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) + +var clock = new FakeTimeProvider(); +var responses = new Queue([HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]); +var handler = new StubHandler(_ => new HttpResponseMessage(responses.Dequeue()) { Content = new StringContent("{}") }); +var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions { TimeProvider = clock }); + +var call = client.{ApiGroup}.{Operation}(request, cancellationToken: default); // a GET: retryable +clock.Advance(TimeSpan.FromSeconds(2)); // past the 1s backoff (+ up to 500ms jitter) +await call; + +Assert.Equal(2, handler.Requests.Count); +``` + +The awaited call does not complete until the fake clock moves past the backoff — start the call, `Advance`, +then `await`. Under DI, register the fake as the container's `TimeProvider` (before *or* after +`Add{Api}Client` — order does not matter) and the SDK seeds the option from it; an explicit +`o.TimeProvider = clock` in the configure callback wins over the container. The SDK never registers a +`TimeProvider` itself, so nothing of yours is overwritten. + +## Construct an SDK exception directly + +When the code under test takes the SDK's exception as *input* — an error-mapping boundary, a retry policy of +your own — build the leaf directly instead of driving a stub through the whole client. Every leaf has a +public primary constructor taking the message and its cause (`(string message, Exception? innerException = +null)` on most; `ResponseDeserializationException` requires the cause, `AuthSchemeException` takes the list +of scheme failures) and sets its `required` members through the object initializer; there is no +`[SetsRequiredMembers]` shortcut, so a fake states the full call identity exactly as the SDK does (the +`For(...)` factories are `internal` because they take engine types): + +```csharp +using {RootNamespace}.Core.Exceptions; + +var timeout = new SdkTimeoutException("GET https://api.example.com/widgets received no response within 10 s.") +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + Timeout = TimeSpan.FromSeconds(10), +}; + +var unreadable = new ResponseDeserializationException( + "GET https://api.example.com/widgets returned a body that could not be deserialized into Widget.", + new System.Text.Json.JsonException("...")) +{ + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.example.com/widgets"), + StatusCode = HttpStatusCode.OK, + Headers = new HttpResponseMessage().Headers, + ContentType = null, + TargetType = typeof(Widget), +}; +``` + +`ApiException` is the one leaf you cannot fabricate: neither `RawError` nor a `{Operation}Error` has +a public constructor. Produce it through the stub handler as in *Test an error path* above. + ## Assert the outgoing request Because the stub captures `LastRequest`, you can assert method, path, query, headers, and body: @@ -153,7 +252,7 @@ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); -await client.{ApiGroup}.{Operation}(/* args */, ct: default); +await client.{ApiGroup}.{Operation}(request, cancellationToken: default); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Contains("/expected/path", handler.LastRequest!.RequestUri!.AbsolutePath); @@ -184,16 +283,25 @@ Assert.Contains("\"expected_field\"", sentJson); `503` therefore proves nothing about what happens when the *connection* fails. The verb filter holds all three at one send for a `POST` — which is exactly the property worth locking down with a test, because it is a *configuration* guarantee and someone widening `HttpMethodsToRetry` for the read path silently - removes it: + removes it. What reaches the test is the SDK's wrapper, never the raw `HttpRequestException`: a failed + send surfaces as `SdkConnectionException` (message `"POST https://host/path could not be sent: connection + reset"`, the raw exception as `InnerException`), and an exhausted per-attempt timeout as + `SdkTimeoutException` with `Timeout` set to the window that elapsed. ```csharp // Transport fault: the stub throws instead of answering, then we count what the server actually received. var handler = new StubHandler(_ => throw new HttpRequestException("connection reset")); var client = new {Api}Client(new HttpClient(handler), new {Api}ClientOptions()); - await Assert.ThrowsAnyAsync(() => client.{ApiGroup}.{Operation}(body, ct: default)); + var ex = await Assert.ThrowsAsync( + () => client.{ApiGroup}.{Operation}(request, cancellationToken: default)); + Assert.IsType(ex.InnerException); Assert.Equal(1, handler.Requests.Count(r => r.Method == HttpMethod.Post)); // no resend ``` +- **Your own cancellation is the one thing the SDK never wraps.** A token you cancel surfaces as the usual + `OperationCanceledException` / `TaskCanceledException`, not as an `SdkException` — so a test that cancels + mid-call asserts on the framework type, and a `catch (SdkException)` in the code under test must be + expected *not* to fire. - For DI-based code, the SDK's `Add{Api}Client` resolves the **default (unnamed)** `IHttpClientFactory` client, so register your stub on that one, then resolve `{Api}Client` from the provider: ```csharp @@ -201,6 +309,8 @@ Assert.Contains("\"expected_field\"", sentJson); services.AddHttpClient(Options.DefaultName).ConfigurePrimaryHttpMessageHandler(() => stubHandler); var client = services.BuildServiceProvider().GetRequiredService<{Api}Client>(); ``` + A `TimeProvider` registered in the same container is picked up by the SDK automatically — see *Fake the + clock* below. - To look up an operation's signature, its request type, or a `{Operation}Error`'s accessor names, take them from the contract sheet (grounded from the SDK map/source) — not a decompiled or reflected view of the installed package, and not memory. diff --git a/plugins/alpaca/skills/python/python-authentication/SKILL.md b/plugins/alpaca/skills/python/python-authentication/SKILL.md index 544f5a543..87665c391 100644 --- a/plugins/alpaca/skills/python/python-authentication/SKILL.md +++ b/plugins/alpaca/skills/python/python-authentication/SKILL.md @@ -211,8 +211,8 @@ Two consequences, and both are the sort of thing that costs an afternoon: underneath name the token source. Reading only the top frame sends you looking in the wrong place entirely. 2. **The non-raising response mode does not protect you.** The `with_raw_response` variant returns a - result object instead of raising *for the operation* — but the token fetch calls `.unwrap()` - internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle + result object instead of raising *for the operation* — but the token fetch passes its own result + through `unwrap()` internally, so an auth failure raises even there. Any code path that touches the SDK needs to handle this, including one written specifically to avoid exceptions. So a boundary that catches only the operation's error union is incomplete. Narrow on the payload type: diff --git a/plugins/alpaca/skills/python/python-calling-endpoints/SKILL.md b/plugins/alpaca/skills/python/python-calling-endpoints/SKILL.md index 4dea813a8..a6e72427c 100644 --- a/plugins/alpaca/skills/python/python-calling-endpoints/SKILL.md +++ b/plugins/alpaca/skills/python/python-calling-endpoints/SKILL.md @@ -1,6 +1,6 @@ --- name: python-calling-endpoints -description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, the two response modes, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. +description: Calling operations on an APIMatic-generated Python SDK — finding the group that owns an operation, the positional/keyword-only split and the parameters that never appear in the signature at all, passing a body as a model or a dict, uploading a file or a raw binary body, the two response modes, consuming a streamed file response, consuming an event stream, per-call request options, and async usage. Load before writing the first call to an SDK operation, or when an operation's shape or return type is unclear. --- # Calling endpoints on an APIMatic Python SDK @@ -88,7 +88,17 @@ at construction. The dict form suits payloads assembled from external data. See **Form and multipart** — there is **no single body parameter**. Each field (and each file) becomes its own parameter, following the same three-way split rule above. Do not go looking for a `body=`; read the -signature. +signature. An optional part is omitted by leaving its parameter at its default: a part passed `None` +is dropped from the body rather than sent empty. + +**Raw binary** — one parameter typed `FileInput` (`AsyncFileInput` on the async client), sent as the +whole body with no wrapper around it. + +**A text body** — the parameter takes the schema's own type: a `str` for a text schema, and the upload +alias above for a binary one. It is sent as the body as it stands and handed back the same way, and +nothing is encoded or decoded for you — where an endpoint asks for base64, pass content that already is +base64 and decode a base64 response yourself. Arbitrary binary belongs in a raw-binary or multipart +position. **No payload** — no body parameter at all. @@ -100,6 +110,31 @@ a payload, pass one. Serialization happens while the request is built, **before** anything is sent — so a body the SDK cannot dump raises out of the call with no request made. See `python-models`. +### File and binary parameters + +**One name per client, whichever position it is.** A multipart file part and a raw binary body take +the same alias, and only the client you called decides which: `FileInput` on the sync client, +`AsyncFileInput` on the async one, and `list[…]` of either for an array part. Both are imported from +`{root_package}.core`. + +Nothing validates a file, so what the alias accepts is wider than the annotation shows — `bytes`, a +`Path`, any object with a binary `read`, a chunk iterator, and an `anyio`-style handle on the async +client. Which one you pass has consequences the signature cannot show: what stays resident, what can +be re-sent, who closes your handle, whether the body goes out sized or chunked, and what the file is +called on the wire. There is **no `str` arm** at either position — wrap a path in `Path`. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) # the default: streams, sized, names itself +``` + +**Load `python-file-handling`** before passing anything other than a `Path`, and before consuming a +file an operation returns. It carries the arm-by-arm decision with its costs, the three behaviours +that differ between a part and a raw body, overriding the filename or media type, and how to read a +`FileResponse` without leaking its connection. + + ## Making the call and reading the response Operations come in two forms, and choosing between them is a real design decision. @@ -112,22 +147,23 @@ value = client.{group}.{operation}(...) ``` **Non-raising — `with_raw_response`.** Returns a result you branch on. Use it when you need the -**status code or response headers** (the parsed form exposes neither on success), or when a non-2xx is +**status code or response headers** (the parsed form exposes neither on success, except for a file +response, which carries its own `headers`), or when a non-2xx is an expected outcome rather than an exception: ```python from {root_package}.core import Success, Failure match client.{group}.with_raw_response.{operation}(...): - case Success(payload=value, response=resp): - print(resp.status_code, value) - case Failure(error=err, response=resp): - print(resp.status_code, err) + case Success(payload=value, status_code=status): + print(status, value) + case Failure(error=err, status_code=status): + print(status, err) ``` Both are frozen dataclasses, so `match` needs no boilerplate and `isinstance` narrowing is equivalent. -`.unwrap()` collapses either back to the payload — which is literally how the parsed form is built: -every parsed method is its raw peer plus `.unwrap()`. +`unwrap(result)` (from `{root_package}.core`) collapses either back to the payload — which is literally +how the parsed form is built: every parsed method is `unwrap(...)` around its raw peer. **`with_raw_response` removes one exception path, not the need for a `try`.** Two failures still raise in both modes: a failed token fetch (it unwraps internally), and any decode failure. See @@ -135,21 +171,45 @@ in both modes: a failed token fetch (it unwraps internally), and any decode fail ### Return types -Three shapes, and the sheet names which one each operation has — the map block states it directly as +Five shapes, and the sheet names which one each operation has — the map block states it directly as **Returns (parsed)** and **Returns (raw)**: - **A decoded model** — the JSON case, and the common one. - **Text** — a `str` or other scalar, for an operation declaring a text response. +- **A file** — `FileResponse` (`AsyncFileResponse` on the async client), for an operation declaring a + binary response. Unlike the others it arrives **unread** and holds a connection; see below. +- **An event stream** — `EventStream[T]` (`AsyncEventStream[T]` on the async client), for an operation + declaring a `text/event-stream` response. It arrives **unread**, holds a connection, and iterating + it yields `T`; see below. - **`None`** — the operation declares no response body. **`-> None` means the call succeeded.** Do not bind it and do not test it — success is "no exception raised", exactly as for the others. Two follow-ons: if you need the resulting state, re-read it with a separate call; and if you need the status code (`200` vs `204`), the raw peer is `ApiResult[None, ...]` -and `Success(payload=None, response=resp)` is the only place it appears. +and `Success(payload=None, status_code=...)` is the only place it appears. Writing `value = client.{group}.{operation}(...)` on a `-> None` operation type-checks under a loose annotation and then fails later with an `AttributeError` on `None`. +### Consuming a file response + +A `FileResponse` is a body that has **not been read yet** and holds its connection until it is, +which makes it the one return value with a lifecycle. Three calls consume it and all three close it — +`save_to(...)`, `read()`, or a drained `iter_bytes()` under `with` — and the async twin mostly +`a`-prefixes them. + +Getting this wrong leaks a connection rather than raising, so **load `python-file-handling`** before +writing the consuming code. It carries the lifecycle in full: which warning fires when one is +abandoned and how to make it fail a test, the single-use rule, what `save_to` does with a directory, +and why a `try` around the call alone catches nothing. + +### Consuming an event stream + +An `EventStream[T]` is iterated, not read: a drained `for` releases its connection, and any early exit +needs `with`. **Load `python-event-streams`** before writing the loop — it carries the lifecycle, the +union narrowing, and the failures that are silent. + + ## Per-call overrides — `request_options` Every operation's last parameter is `request_options`, the SDK's single per-call override channel, @@ -159,7 +219,8 @@ accepted typed or dict-shaped: client.{group}.{operation}(..., request_options={"timeout": 5.0}) ``` -The keys are exactly `timeout` (seconds, must be > 0) and `extra_headers`. It is validated with +The keys are exactly `timeout` (seconds, must be > 0), `extra_headers`, and the two retry overrides +`max_retries` and `status_codes_to_retry` (`python-configuration-resilience`). It is validated with `extra="forbid"`, so `{"timeuot": 5}` raises `ValidationError` rather than being ignored — and because the dict form is a closed `TypedDict`, a type checker catches it at the call site first. @@ -210,11 +271,10 @@ results = await asyncio.gather(*(one(a) for a in args)) There is no cancellation-token parameter. Python's own mechanisms apply: -- **Per call** — `request_options={"timeout": ...}`, the direct way to bound one request. -- **Around a block** — `asyncio.timeout(...)` (3.11+) or `asyncio.wait_for`, to bound a whole operation - including your own surrounding work. Cancellation raises `CancelledError`/`TimeoutError` through the - await, which an `except ApiError` clause will **not** catch. -- **Sync code has no external cancellation.** The timeout *is* the mechanism, so set one. +- **Per call** — `request_options={"timeout": ...}`, which sets the limit on each wait for this one request. +- **The whole call** — `asyncio.wait_for(...)` around it, in async code; see + `python-configuration-resilience`. +- **Sync code has no whole-call limit.** The timeout only limits each wait — set one anyway. ## Next diff --git a/plugins/alpaca/skills/python/python-client-initialization/SKILL.md b/plugins/alpaca/skills/python/python-client-initialization/SKILL.md index b6c83f201..357e62847 100644 --- a/plugins/alpaca/skills/python/python-client-initialization/SKILL.md +++ b/plugins/alpaca/skills/python/python-client-initialization/SKILL.md @@ -24,6 +24,7 @@ constructor: *, # server selection — one of four shapes, see "Choosing the server / base URL" timeout: float = 30.0, # seconds; the generator's default + retry_options=None, # None: the default policy; 0 disables; a count, RetryOptions or dict tunes it custom_http_client=None, # your own transport {scheme}=None, # one credentials keyword per declared scheme {scheme}_token_source=None, # managed-OAuth schemes only @@ -183,7 +184,9 @@ thread-safe. The async client may be built outside a running loop, but from its ## Supplying your own transport The transport is a `Protocol` — a structural interface, not a base class. The sync one requires -`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`: +`send(request) -> HttpResponse` and `close()`; the async one `send` and `aclose()`. There is one +request seam whatever the operation returns, a file included, so a transport that implements those +two satisfies the Protocol under `mypy --strict`: ```python client = {Api}Client(custom_http_client=MyTransport(), {scheme}=...) @@ -197,6 +200,7 @@ class LoggingTransport: def __init__(self, inner): self._inner = inner def send(self, request): + # The head only: the body is still on the socket, and the caller closes it. response = self._inner.send(request) log.info("%s %s -> %s", request.method, request.url, response.status_code) return response @@ -204,9 +208,17 @@ class LoggingTransport: def close(self): self._inner.close() ``` -Three obligations the protocol places on anything you supply, all silent when broken: **do not mutate +`send` returns an `HttpResponse` — `url`, `status_code`, lowercased `headers`, +`iter_bytes(chunk_size)`, `read()` and `close()` — whose body has **not** been read. That is true of +every response, not just a download, so a wrapper must hand each one back unread; logging its size +means consuming it, which leaves the caller nothing to read. `iter_bytes(None)` must yield each chunk +as the network delivered it, holding nothing back — an event stream reads it that way, and a wrapper +that re-chunks to a fixed size delays every small event. + +Four obligations the protocol places on anything you supply, all silent when broken: **do not mutate the incoming request** (it is frozen); **honour `request.timeout`** when set, falling back to your own -when it is `None`; and **lowercase the response header names**, because callers look them up that way. +when it is `None`; **lowercase the response header names**, because callers look them up that way; and +**return once the head has arrived**, leaving the body for whoever reads it. Two consequences. First, **the `timeout=` you passed to the client no longer reaches the wire** — that value only builds the client's *own default* transport, so set the timeout on the one you pass or you @@ -219,4 +231,4 @@ yourself while the client is alive, and don't share one transport between two cl - Configure authentication → **python-authentication** - Make your first call → **python-calling-endpoints** -- Tune timeouts, base URLs, proxies → **python-configuration-resilience** +- Tune timeouts, retries, base URLs, proxies → **python-configuration-resilience** diff --git a/plugins/alpaca/skills/python/python-configuration-resilience/SKILL.md b/plugins/alpaca/skills/python/python-configuration-resilience/SKILL.md index 4b32cf04e..05c86314f 100644 --- a/plugins/alpaca/skills/python/python-configuration-resilience/SKILL.md +++ b/plugins/alpaca/skills/python/python-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: python-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the fact that the SDK performs NO retries and what that leaves you to build, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. +description: Client configuration and resilience for an APIMatic-generated Python SDK — server and base-URL selection, the retry policy (on by default, off with `max_retries=0`) and what it will and will not repeat, what a timeout actually bounds, proxies and TLS, and request/response logging through the transport seam. Load before you configure or tune the client — the keyword names alone do not reveal what is handled for you and what is not. --- # Configuration & resilience for an APIMatic Python SDK @@ -9,8 +9,8 @@ description: Client configuration and resilience for an APIMatic-generated Pytho > `{group}`, `{root_package}`) — replace it with the concrete identifier from the source. Configuration is not an options object here. Everything is either a **constructor keyword** on the -client (`python-client-initialization`), a field on a **server config model**, or a **per-call -`request_options`**. There is no third place to look. +client (`python-client-initialization`), a field on a **config model** — the server config or +`RetryOptions` — or a **per-call `request_options`**. There is no third place to look. ## What this SDK does not do for you @@ -19,14 +19,17 @@ following, so each is yours to build or deliberately do without: | Capability | Status | What to do instead | |---|---|---| -| **Retries / backoff** | none — ruled out by design | Wrap your own call; see below | -| **Streaming / SSE** | none — responses are fully buffered `bytes` | Not available through this SDK | +| **Streaming — file responses** | a `FileResponse` arrives with its body unread and holds its connection until you consume it | `save_to` / `read` / `iter_bytes` (see `python-file-handling`) | +| **Streaming — SSE** | an `EventStream` arrives with its body unread; iterating yields each event's decoded data, and `timeout` bounds the gap between events | a `for` loop, or `with` for an early exit (see `python-event-streams`) | | **Request/response logging** | none — no hook, no event | Wrap the transport; see below | | **Circuit breaking, rate limiting** | none | Your own, around the call | | **A mock/offline mode** | none | Point `base_url` at a mock server, or supply a fake transport (`python-testing`) | +**Retries are on by default.** A client built without `retry_options` retries idempotent calls up to +three times; set `max_retries` to `0` to turn that off — see *Retries* below. + What the SDK *does* own: the request pipeline, auth application and token caching, response decoding, -error mapping, connection pooling, and one timeout. +error mapping, connection pooling, one timeout, and — once you turn it on — the retry loop. ## Server and base-URL configuration @@ -83,123 +86,177 @@ Where the SDK manages OAuth, **the token endpoint is derived from the same serve auth can never drift apart. Pointing `base_url` at a mock server or recording proxy therefore redirects the token fetch too. -## Retries — there are none +## Retries — on by default + +A client built without `retry_options` uses the **default policy** in the table below: a `GET`, `HEAD`, +`PUT` or `OPTIONS` call that meets a `408`, `429`, `500`, `502`, `503`, `504` or a dropped connection is +retried up to three times. +Retrying is configured by one options class, `RetryOptions` (from `{root_package}.core`), passed to the +client in any of four spellings: + +```python +from {root_package}.core import HttpMethod, RetryOptions + +client = {Api}Client(retry_options=3, {scheme}=...) # a count: max_retries=3, every other field at its default +client = {Api}Client(retry_options={}, {scheme}=...) # every field at its default, as omitting it does +client = {Api}Client(retry_options={"max_retries": 5}, {scheme}=...) # a dict: the fields you name, the rest at their defaults +methods: frozenset[HttpMethod] = frozenset({"GET"}) # held in a variable: annotate it +client = {Api}Client( + retry_options=RetryOptions(max_retries=5, http_methods_to_retry=methods), + {scheme}=..., +) # the model itself +``` + +**Turning retries off** is `max_retries` set to `0`, in whichever spelling: `retry_options=0`, +`{"max_retries": 0}` or `RetryOptions(max_retries=0)`. Leaving `retry_options` out, or passing `None`, +`{}` or `RetryOptions()`, all give the **default policy** — none of them turns retrying off. + +`RetryOptions` is a frozen pydantic model and its dict companion is a closed `TypedDict`, so a misspelt +key or an out-of-range value (a negative count, a `backoff_factor` below `1`) raises `ValidationError` +at construction rather than being ignored. + +| Field | Default | Meaning | +|---|---|---| +| `max_retries` | `3` | Retries *after* the first attempt; `0` disables | +| `initial_delay` | `1.0` | Seconds before the first retry | +| `backoff_factor` | `2.0` | Multiplier per retry; `1` keeps the delay constant | +| `max_delay` | `60.0` | Ceiling on any one wait, a `Retry-After` included | +| `max_jitter` | `0.5` | Widest random addition to a wait, in seconds; `0` removes it | +| `status_codes_to_retry` | `frozenset({408, 429, 500, 502, 503, 504})` | Statuses that qualify | +| `http_methods_to_retry` | `frozenset({"GET", "HEAD", "PUT", "OPTIONS"})` | Methods that may be repeated at all | + +The wait before retry *n* (from 0) is `min(initial_delay × backoff_factor ** n + jitter, max_delay)`, +or the server's `Retry-After` — seconds or an HTTP date — capped at `max_delay` when it sends one. -**This is the headline fact, and it is the opposite of what most SDK generators do.** There is no retry -policy, no backoff, no pipeline to configure. An architectural decision record rules retries out of the -request path deliberately. Concretely: +A call is repeated only when **all** of these hold: -- A `429` or `503` raises on the first attempt. Nothing is resent. -- A connection reset raises. Nothing is resent. -- Even the one place that looks automatic — a `401` — is not a retry. The cached credential is - invalidated so the *next* call re-authenticates; the failing request is not resent. You see one - `401`, then recovery. +- its method is in `http_methods_to_retry` — this gates dropped connections and timeouts as well as + statuses, so the default never repeats a `POST`, `PATCH` or `DELETE`; +- it failed with a status in `status_codes_to_retry`, or with no response at all; +- its body can be sent again — `bytes`, a `Path`, JSON, form and text bodies can; a binary body or a + multipart file read from an open stream cannot; +- retries remain. -Two implications, and the second is easy to give away by accident: +A `401` is never in the default set; it invalidates the cached credential for the next call instead +(`python-authentication`). Whatever you add to `status_codes_to_retry` is retried, so leave out +statuses that cannot succeed a second time. When the last attempt fails, you see what a single attempt +would give you: the `ApiError`, the raw response's `Failure`, or the HTTP library's own exception. -1. **Whatever retrying your integration needs, you write**, outside the SDK call. There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +A **custom transport** must raise `TransportError` (`from {root_package}.core import TransportError`) +for a failed send to be retried; the shipped `Httpx2Client` does. Anything else it raises reaches you on +the first attempt. -### Adding retries yourself +### Per-call overrides -Use `tenacity`, `backoff`, or a plain loop; wrap **your** call, and decide per operation: +`request_options` overrides two fields for one call; the rest stay the client's: ```python -import httpx -from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from {root_package}.core import ApiError - -def _is_transient(e: BaseException) -> bool: - if isinstance(e, httpx.TimeoutException | httpx.ConnectError): - return True - return isinstance(e, ApiError) and e.status_code in {429, 500, 502, 503, 504} - -@retry( - retry=retry_if_exception(_is_transient), - stop=stop_after_attempt(3), - wait=wait_exponential_jitter(initial=1, max=10), - reraise=True, -) -def fetch(resource_id: str): - return client.{group}.{operation}(resource_id) # a read: safe to retry +client.{group}.{operation}(arg, request_options={"max_retries": 0}) # this call: no retries +client.{group}.{operation}(arg, request_options={"status_codes_to_retry": frozenset({429, 503})}) # this call: a narrower set ``` -Rules to hold to: +**Do not wrap the call in a second retry layer** (`tenacity`, a loop of your own): attempts multiply, +and each of your attempts is a new call with a new idempotency key (below). If you need behaviour the +options cannot express, turn the SDK's off for that call and own it. + +**Only a write that was sent may have landed.** A write that timed out *after it was sent* may have +succeeded — a reset after the bytes reached the server is indistinguishable from one before. A write +that was never sent did not happen; `python-error-handling` names the four exceptions that mean so. -- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out - may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. -- **Never retry a `ValidationError`, or a 4xx other than `429`.** They cannot succeed on a second try. - A decode failure in particular is not transient (`python-error-handling`). -- **Respect `Retry-After`** when present: `e.response.headers.get("retry-after")` — header keys are - lowercased, so look it up that way. -- **Cap the worst case.** `attempts × timeout + backoff` must sit below whatever deadline your own - caller works to, or you are burning provider capacity for a response nobody is waiting for. +**The retry arithmetic is not a ceiling.** `(max_retries + 1) × timeout` plus the waits between them +can be exceeded, because one attempt can outlast its timeout; see *Bounding a call*. ### Making a write safe when you retry -Some write operations declare an **idempotency-key parameter** — a header the provider uses to collapse -a duplicate submission. Whether a given operation has one is per-operation and visible only in its -signature, so **check before you assume**; in practice a description declares them on some writes and -not others. +**Every operation other than a `GET` sends an `Idempotency-Key` header** — a UUID minted once per call, +unless the description declares its own header of that name, in which case that one is a keyword you +set. Within the SDK's retry loop every attempt carries the *same* key, which is what makes adding `POST` +to `http_methods_to_retry` safe **where the provider deduplicates on it**. A retry you write yourself is +a new call and mints a new key — the provider sees two submissions. -Where one exists, generate the key **once per logical action** and reuse it across every attempt: +Where the operation declares the parameter itself, derive the key from what the request is for, and +reuse it on every attempt and every repeat: ```python -request_id = str(uuid.uuid4()) # once per logical action, not per attempt +request_id = derive_ref(request) # the same on every attempt and every repeat — never uuid4() client.{group}.{operation}(body, {idempotency_key}=request_id) ``` Two things the signature will not tell you, both of which need the provider's own documentation: -- **Whether the provider actually enforces it.** The parameter existing is not a guarantee that a +- **Whether the provider actually enforces it.** The header being sent is not a guarantee that a resend is deduplicated. - **The retention window.** Keys expire, and windows differ per API — so a resend after a long backoff may no longer be collapsed. -**Where an operation has no idempotency parameter, retrying it is a real duplication risk.** The safe -recovery is to re-read state and decide, not to resend. Some such operations are naturally idempotent -(cancelling an already-cancelled resource is usually harmless), but that is a per-operation judgement — -make it deliberately rather than retrying the whole class. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep `POST` out +of the gate; the safe recovery is to re-read state and decide, not to resend. Some such operations are +naturally idempotent (cancelling an already-cancelled resource is usually harmless), but that is a +per-operation judgement — make it deliberately rather than retrying the whole class. + +### ⚠⚠ The same operation arriving twice + +Everything above stops one call being *sent* twice. It does not stop one operation being *requested* +twice — a double-submit, a caller retry, a redelivered message, two workers racing. The SDK's own +`Idempotency-Key` does not help here: it is new on every call. Stopping it needs a claim that outlives +the request and the process, taken atomically: + +```python +ref = derive_ref(request) # from what the request is for — never uuid4() +if not try_claim(ref): # insert-or-fail: the store rejects the second claim, you catch it + return load_existing(ref) # may still be in progress — do not report it done +result = client.{group}.{operation}(...) # `ref` as its idempotency key, if the operation declares one +complete(ref, result) +``` + +Build `try_claim` on whatever this codebase already uses to enforce uniqueness, and add no new store or +dependency for it. If it has nothing that can hold such a claim, stop and raise that with the user. + +⚠⚠ An in-process lock (`threading.Lock`, `asyncio.Lock`, a module-level dict), a single-process or +single-host assumption or an exists-then-create read is no guard here: it lets both callers through — +and documenting it as a known limitation does not change that. ## Bounding a call — what the timeout actually bounds -One knob, in two places: +One knob, in two places — and, for async code, a limit on the whole call: ```python -client = {Api}Client(timeout=10.0, {scheme}=...) # every request -client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # this one request +client = {Api}Client(timeout=10.0, {scheme}=...) # each wait, every request +client.{group}.{operation}(arg, request_options={"timeout": 3.0}) # each wait, this one request +await asyncio.wait_for(async_client.{group}.{operation}(arg), budget) # the whole call (Async{Api}Client) ``` - The value is **seconds, as a float**; the per-call option overrides the client's. - It must be **> 0**. The constructor raises `ValueError` otherwise — and rejects NaN too, since the guard is written `not timeout > 0`. -- The default is **30 seconds**, which is far too long for anything on a user-facing request path. Set - it deliberately. +- The default is **30 seconds** per wait. - Under the default transport, one float sets connect, read, write and pool timeouts alike. To separate them (a short connect, a longer read), build your own transport with an - `httpx.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. + `httpx2.Timeout(connect=..., read=...)` and pass it as `custom_http_client`. -**Because there are no retries, the timeout genuinely bounds the call.** Worst case is one timeout, not -attempts × timeout. That simplification is what the no-retry design buys you — and the moment you add -retries, the arithmetic above becomes yours. +**The timeout limits each wait, not the whole call.** Connecting, sending and receiving each have their +own clock, and the receiving clock starts again every time a piece of the reply arrives. So a reply that +keeps arriving slowly is never cut off, and each attempt — up to `max_retries + 1` of them — can take far +longer than the number you set. This timeout firing is a transport failure and retries on the curve +like any other; a `408` is the server's request timeout and sits in the default status set. **If you supply `custom_http_client`, the client's `timeout=` no longer reaches the wire.** That value only builds the SDK's *own* default transport. Set the timeout on the transport you pass, and honour `request.timeout` inside it (`python-client-initialization`). -For async callers, a deadline over a whole operation — the SDK call plus your own surrounding work — is -`asyncio.timeout(...)`. It raises `TimeoutError` through the await, which no `except ApiError` clause -will catch. +To limit a whole call, retries included, async code wraps it in `asyncio.wait_for(...)` +(`asyncio.timeout(...)` needs 3.11, and this SDK supports 3.10); map its `asyncio.TimeoutError` as +`python-error-handling` shows. Sync code has no whole-call limit through this SDK. A file or +event-stream call returns at the head, so put the limit around the consuming code too. ## Proxies and TLS The default transport takes these directly, and you pass it in yourself to set them: ```python -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client -transport = HttpxClient(timeout=10.0, proxy_url="http://proxy:3128", verify=True) +transport = Httpx2Client(timeout=10.0, proxy_url="http://proxy:3128", verify=True) client = {Api}Client(custom_http_client=transport, {scheme}=...) ``` @@ -221,7 +278,7 @@ it, delegate to the real one, and log around the call. ```python import logging, time -from {root_package}.core import HttpxClient +from {root_package}.core import Httpx2Client log = logging.getLogger(__name__) @@ -230,6 +287,7 @@ class LoggingTransport: def send(self, request): started = time.monotonic() + # The head only -- do not read the body here; the caller owns it. response = self._inner.send(request) log.info( "%s %s -> %s (%.0f ms)", @@ -241,11 +299,18 @@ class LoggingTransport: def close(self): self._inner.close() client = {Api}Client( - custom_http_client=LoggingTransport(HttpxClient(timeout=10.0)), {scheme}=... + custom_http_client=LoggingTransport(Httpx2Client(timeout=10.0)), {scheme}=... ) ``` -The async version is the same shape with `async def send` and `async def aclose`. +The async version is the same shape with `async def send` and `async def aclose`. Both members are +required, and they are the only two: one request seam serves every operation, a download included, +so there is no second method to forget. + +**The elapsed time above bounds the head, not the call.** `send` returns once the response head has +arrived, so a body still on the wire is not in that measurement — for a download it is barely in it +at all. Timing the whole exchange means timing where the body is consumed, which is the caller's +code, not this wrapper. **Log the method, URL and status — not headers or bodies.** The auth header carries a live credential and bodies carry whatever the API moves; neither belongs in your logs or your traces. If you must diff --git a/plugins/alpaca/skills/python/python-error-handling/SKILL.md b/plugins/alpaca/skills/python/python-error-handling/SKILL.md index 3afe61f7d..02bf6bbea 100644 --- a/plugins/alpaca/skills/python/python-error-handling/SKILL.md +++ b/plugins/alpaca/skills/python/python-error-handling/SKILL.md @@ -30,8 +30,8 @@ not at the `except`. | Attribute | Type | Notes | |---|---|---| | `e.error` | the operation's error union | The decoded body. **This is the information.** | -| `e.response` | `HttpResponse` | `status_code`, `headers`, `content`, `text()`, `json()` | -| `e.status_code` | `int` | Shortcut for `e.response.status_code` | +| `e.status_code` | `int` | The status that failed. | +| `e.headers` | `Mapping[str, str]` | The response head, keys lowercased. | **`str(e)` and `repr(e)` deliberately omit the body.** They render as `HTTP 422: {TypedError}` — the status and the payload's *type name* only, so response bodies stay out of logs and tracebacks by @@ -59,8 +59,8 @@ Four places give you that alias, in order of preference: This is where the sheet's row should have come from. 3. **The operation's docstring**, whose `Raises:` section ends with the union verbatim: `` `error` is `{TypedError} | RawError`. `` -4. **The error module itself**, whose `map` is a `match` on `response.status_code` naming the schema - each status decodes to. +4. **The error module itself**, whose `map` is a `match` on `status_code` naming the schema each + status decodes to. **`RawError` is always the last arm.** The generator emits it unconditionally as the catch-all, so every operation can hand you a `RawError` for any status it does not document — there is no operation @@ -84,7 +84,10 @@ An operation can also declare **more than one** typed arm — one per documented *range* (`4XX`, `5XX` are legal), so a union may read `{TypedError1} | {TypedError2} | RawError`. Handle each arm you declared. -Narrow with `match` or `isinstance`; both are understood by a type checker: +Narrow with `match` or `isinstance` — a type checker understands both — on a class arm only; a `oneOf` +alias is not a class either, so match its variants. After narrowing the class arms, what remains is +the `Any`, `list[...]` or `dict[...]` body: branch on `e.status_code` there. `isinstance(e.error, Any)` +and `case Any()` raise `TypeError`, and mypy flags neither. ```python from {root_package}.core import ApiError, RawError @@ -131,15 +134,19 @@ match client.{Group}.with_raw_response.{Operation}(...): ``` `Success` and `Failure` are frozen dataclasses, so pattern matching needs nothing added; both carry -`.response`, which is **the only way to read response headers**. `.unwrap()` collapses either back to -the raising behaviour — in fact every parsed method is literally its raw peer plus `.unwrap()`. +`.status_code` and `.headers` as fields of their own. On the **success** path this mode is the only +way to read either — except for a file response, which carries its own `headers` on the parsed path. +On the failure path `ApiError` already carries both, so the choice there is about whether you want an +exception, not about what you can see. `unwrap(result)` (from `{root_package}.core`) collapses either +back to the raising behaviour — in fact every parsed method is literally `unwrap(...)` around its raw peer. The split is mechanical: **2xx is `Success`, everything else is `Failure`.** No nuance, no per-status judgement. Use this variant when you need the status code or headers on the *success* path, or when a non-2xx is an expected outcome rather than an exception. **This mode is not exception-free.** See the next section: a decode failure raises in both modes, and -so does a failed token fetch. +so does a failed token fetch. Mapping a `Failure` to your own error uses the same function as the +raising path — see *Guarding every call site*. ## The failures that are not `ApiError` @@ -178,7 +185,7 @@ guard belongs somewhere else than you would place it by habit: ```python value = client.{Group}.{Operation}(...) if value.{member} is UNSET: - raise ProviderUnreadable("{Operation} returned no {member}; outcome unknown") + raise ProviderError(502, "{Operation} returned no {member}", outcome_unknown=True) ``` **Assert on the members you depend on, immediately after every call that matters.** For a write, an @@ -187,27 +194,91 @@ taken effect and you cannot name what it created. Nothing in the SDK does this f ### Transport failures — the HTTP library's exceptions, unwrapped -The SDK does **not** wrap its transport's exceptions. The protocol says implementations *may raise -their underlying library's exceptions*, and the default transport does not catch: connection refused, -DNS failure, TLS error, dropped socket and timeout all propagate through the SDK untouched. With the -shipped transport those are `httpx` exceptions: +The SDK does **not** wrap its transport's exceptions where you catch them. The protocol says +implementations *may raise their underlying library's exceptions*; the shipped transport reports a +failed send to the retry loop, and when the last attempt fails the pipeline raises the library's own +exception. So connection refused, DNS failure, TLS error, dropped socket and timeout all reach you as +`httpx2` exceptions. + +⚠ **`httpx2.HTTPError` is the base class of every one of them, timeouts included.** A lone +`except httpx2.HTTPError` arm therefore catches connection-refused and read-timeout together and gives +them one outcome — and those are opposite facts. + +**Which transport failures reached the server, and which did not.** This is a property of the +exception class, not a judgement call: + +| exception | did the request reach the server? | outcome | +| --- | --- | --- | +| `httpx2.ConnectError` | **no** — refused, DNS failure, TLS rejected | **known**: nothing happened | +| `httpx2.ConnectTimeout` | **no** — the connection was never established | **known**: nothing happened | +| `httpx2.PoolTimeout` | **no** — no connection was ever acquired from the pool | **known**: nothing happened | +| `httpx2.ProxyError` | **no** — the proxy refused before forwarding | **known**: nothing happened | +| `httpx2.ReadTimeout` | **maybe** — the request went out, the reply never came | **unknown** | +| `httpx2.WriteTimeout` | **maybe** — sending was interrupted mid-body | **unknown** | +| `httpx2.RemoteProtocolError` | **maybe** — the peer closed after the request went out | **unknown** | + +`ConnectTimeout`, `PoolTimeout` and `ReadTimeout` are all `httpx2.TimeoutException` and they fall on +**opposite sides of this table**, so splitting on `TimeoutException` does not split on the thing that +matters. Split on whether the request was ever sent: ```python -import httpx +import httpx2 -except httpx.TimeoutException as e: # ConnectTimeout / ReadTimeout / … +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + # Nothing reached the provider. Fail the operation outright; there is nothing to reconcile. ... -except httpx.HTTPError as e: # base class for the rest +except httpx2.RequestError as e: + # It may have landed. Record the outcome as UNKNOWN and reconcile before any retry. ... ``` -**A write that fails this way has an unknown outcome** — a reset after the bytes reached the server is -indistinguishable from one before. +Catch `httpx2.RequestError`, not `httpx2.HTTPError`, in that second arm. `HTTPError` is also the base of +`HTTPStatusError` — what `raise_for_status()` raises — and that one means a **complete response +arrived**, so sweeping it into the unknown-outcome arm mislabels the one case that is fully known. + +⚠ **`PoolTimeout` is the one to get right.** It is the characteristic failure of a saturated sync +client, which is the setup this skill recommends — so it arrives *in bulk*, exactly when the system is +already under strain. Marking it unknown floods reconciliation with work at the worst moment, for +requests that were never sent. + +**Only the second arm has an unknown outcome.** Marking a refused connection "unknown" is not the +cautious default: it schedules reconciliation for an event that provably did not happen, and it +trains the caller to distrust a failure that is in fact definitive. Note the coupling: your error boundary imports the transport's exception types because the SDK's transport does. If you supply a custom transport (`python-client-initialization`), it is *your* transport's exceptions that arrive and this clause needs revisiting. +### A streamed download moves the failure out of the call + +An operation that returns a file is one place where the call returning successfully does not mean +the transfer succeeded. The pipeline yields as soon as the response *head* arrives; the body is still +on the socket. So a connection dropped mid-transfer raises out of `read()`, `iter_bytes()` or +`save_to(...)` — not out of the call — and a `try` wrapped around the call alone catches nothing: + +```python +f = client.{group}.{operation}(...) # returns on the head; nothing is downloaded yet +try: + f.save_to("out.bin") # the transport failure surfaces here +except httpx2.RequestError: + ... +``` + +Three consequences. The status code and headers are trustworthy before the body is — a `Failure` on a +streamed call is a real outcome, not a guess. A partial write is possible, which is why `save_to` +stages through `.part` and renames only on success, so a failed download never appears under the +final name. And `request_options={"timeout": ...}` bounds **each read**, not the call as a whole, so a +stalled transfer raises rather than hanging. The same holds on the async client, with the failure +raising out of the awaited consumer. `python-calling-endpoints` has the lifecycle itself. + +### An event stream raises from the loop + +An operation that returns an event stream succeeds as soon as the head arrives, like a download. What +follows raises from the **loop**: a frame the event type rejects raises pydantic's `ValidationError` +after the stream has closed, an oversized event or bad UTF-8 raises `ValueError`, and a dropped or +stalled connection raises `httpx2.RequestError` — there is no SSE exception class. +`python-event-streams` has the full table. + ### Authentication failures wear the same exception A failed token fetch raises `ApiError` — but its payload is `OAuthProviderError | RawError`, **not** @@ -226,7 +297,7 @@ from {root_package}.core import OAuthProviderError except ApiError as e: if isinstance(e.error, OAuthProviderError): - raise ConfigurationError(f"credentials rejected: {e.error.error}") from e + raise ProviderError(502, "provider refused our credentials") from e ... ``` @@ -250,32 +321,115 @@ job, a health check). A connection failure during a read fails just as hard as o call left unguarded next to one that is guarded is the one that breaks. **Convert failures to your own type in one place**, so the rest of the code has a single failure type -rather than four unrelated ones. Order matters — most specific first, and auth before the operation's -own union: +rather than four unrelated ones. The type is yours — the SDK's only exception class is `ApiError`; +transport and decode failures arrive as library exceptions, below. If your codebase already has an +error type, give it these two fields instead of adding another: + +```python +class ProviderError(Exception): + def __init__(self, status_code, message, *, outcome_unknown=False): + super().__init__(message) + self.status_code = status_code # the status your boundary returns + self.outcome_unknown = outcome_unknown # whether anything may have happened upstream +``` + +One map turns the provider's answer into it, keyed on the status and the decoded body — the same +arms whichever way you called (below): ```python -import httpx +import httpx2 from pydantic import ValidationError from {root_package}.core import ApiError, OAuthProviderError from {root_package}.models import {TypedError} +def provider_error(status, error) -> ProviderError: + match (status, error): + # OUR credentials or OUR quota: the caller did nothing wrong and cannot fix it. + case (401 | 403, _) | (_, OAuthProviderError()): + return ProviderError(502, "provider refused our credentials") + case (429, _): + return ProviderError(503, "rate-limited") + + # The provider rejected THE CALLER'S request: hand back the same status so they can act on it. + case (s, {TypedError}() as body) if 400 <= s < 500: + return ProviderError(s, body.{message_field}) + case (s, _) if 400 <= s < 500: + return ProviderError(s, "The provider rejected the request.") + + # A provider 5xx: no meaningful caller status, and a write may have landed. + case _: + return ProviderError(502, "Provider unavailable.", outcome_unknown=status >= 500) + try: result = client.{Group}.{Operation}(...) except ApiError as e: - if isinstance(e.error, OAuthProviderError): - raise ProviderConfigError("credentials rejected") from e # 5xx: our misconfig - if isinstance(e.error, {TypedError}): - raise ProviderRejected(e.status_code, e.error.{message_field}) from e # map 4xx -> 4xx - raise ProviderFailure(e.status_code, e.error.text()) from e # RawError arm + raise provider_error(e.status_code, e.error) from e except ValidationError as e: - raise ProviderUnreadable("unreadable response; outcome unknown") from e # do NOT assume failure + raise ProviderError(502, "unreadable response", outcome_unknown=True) from e # do NOT assume failure + +except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.PoolTimeout, httpx2.ProxyError) as e: + raise ProviderError(502, "Never sent.", outcome_unknown=False) from e # KNOWN: nothing happened -except httpx.HTTPError as e: - raise ProviderUnavailable("provider unreachable; outcome unknown") from e +except httpx2.RequestError as e: + raise ProviderError(504, "No response.", outcome_unknown=True) from e # UNKNOWN: may have landed ``` +**Your own time limit needs its own arm.** In async code, an `asyncio.wait_for` around the call raises +`asyncio.TimeoutError`, which none of these arms catches — map it like a read timeout: +`ProviderError(504, "No response.", outcome_unknown=True)`. + +**The raw variant maps through the same function.** A `Failure` is a value, not an exception, so there +is nothing to chain `from`; and a refused token fetch still raises `ApiError` here (it happens before +the request is sent), so the `except ApiError` above stays around the raw call too: + +```python +match client.{Group}.with_raw_response.{Operation}(...): + case Success(payload=value): + ... + case Failure(error=err, status_code=status): + raise provider_error(status, err) +``` + +Note the last two arms, and their order: the narrow tuple must come **first**, because +`httpx2.RequestError` is its base class and would otherwise swallow it. The two are different facts, +and a single `except httpx2.HTTPError` merges them. + +⚠ **Two arms raising the same class with the same values is not a split.** The distinction has to +reach something that acts on it, or it is a comment. Note that both arms above raise the **same +class** on purpose — what differs is the **values they pass**, so there is no way to write either +line without deciding which case you are in. Two different class names carrying the same status and +no flag gives you two `except` arms, two messages, and **one behaviour**. + +The carrier is the two fields on `ProviderError` above, and both arms must set them; and somewhere +downstream, something that branches on **both** of them: + +```python +except ProviderError as e: + if e.outcome_unknown: + schedule_reconciliation(operation, reference) # settle what actually happened + return error_response(e.status_code, safe_message) # 502 never sent · 504 outcome unknown +``` + +**Then write the test that tells the two apart.** This is what decides whether the split is real. The +check is not "are there two arms" but **"does one assertion pass for both inputs?"** — if it does, the +distinction is not real, however well the production branch reads: + +```python +def test_unsent_is_not_the_same_failure_as_unknown(): + unsent = failure_from(httpx2.ConnectError("refused")) # never left the process + unknown = failure_from(httpx2.ReadTimeout("no reply")) # may have landed + + assert (unsent.status_code, unsent.outcome_unknown) == (502, False) + assert (unknown.status_code, unknown.outcome_unknown) == (504, True) + assert unsent.status_code != unknown.status_code # the caller can tell them apart + assert reconciled() == [unknown.reference] # and only one reconciles +``` + +Two tests asserting the *same* exception for a refused connection and a read timeout is this defect +wearing a passing suite. + Always `raise ... from e`. Losing `__cause__` costs you the traceback that names which of the four paths you were on. @@ -285,11 +439,16 @@ paths you were on. apply the identical ladder at every call site. When the same kind of failure becomes a different result on a different operation, callers cannot reason about it. -**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** (validation, -conflict, not-found) is actionable by your caller and should surface as a client 4xx. A transport -failure or an unknown error has no meaningful client status and belongs at 5xx. Collapsing everything -into one blanket status discards the only signal separating "you sent something invalid" from "the -provider is down". +**Keep distinct failures distinct — carry the provider's status.** A provider **4xx** the caller can +act on (validation, conflict, not-found) surfaces as that same client 4xx; a transport failure or an +unknown error has no meaningful client status and belongs at 5xx. Collapsing everything into one +blanket status discards the only signal separating "you sent something invalid" from "the provider is +down". + +**Not every provider failure is the caller's fault.** A `401`/`403` means *your* credentials are wrong, +and a `429` means *your* quota is spent — passing either straight through tells the caller they are +unauthenticated or throttled when they are neither. Those belong at 5xx (the `match` above), and so +does the default arm. **An unreadable body is two cases, not one — decide which before you map it.** An unreadable *success* body is genuinely unknown: 5xx. An unreadable *error* body is not — the provider rejected @@ -307,12 +466,13 @@ and field-path detail. Log the detail, return a message you wrote. ## Notes -- **There are no retries in this SDK.** No status is retried and nothing is resent on a transport - failure — whatever you want retried, you build. See `python-configuration-resilience`. -- **A `401` invalidates the cached credential but does not retry the request.** The caller sees one - `401`, and the *next* call obtains a fresh token. Do not read a single 401 as a permanent - credential failure. +- **Retries are on by default.** A retryable status or transport failure surfaces only after the last + attempt, and the exception you catch is the same one a single attempt would raise. With `max_retries` + set to `0` nothing is resent. See `python-configuration-resilience`. +- **A `401` invalidates the cached credential but does not retry the request.** Your code sees one + `401` (your boundary answers it as `502`), and the *next* call obtains + a fresh token. Do not read a single 401 as a permanent credential failure. - `ApiError` implements `__reduce__`, so it pickles — it survives crossing a process boundary (a Celery result, a multiprocessing queue) with its payload intact. -- `e.response.headers` has **lowercased keys**, guaranteed by the transport contract — look up +- `e.headers` has **lowercased keys**, guaranteed by the transport contract — look up `"x-request-id"`, never `"X-Request-Id"`. diff --git a/plugins/alpaca/skills/python/python-event-streams/SKILL.md b/plugins/alpaca/skills/python/python-event-streams/SKILL.md new file mode 100644 index 000000000..90ce063fa --- /dev/null +++ b/plugins/alpaca/skills/python/python-event-streams/SKILL.md @@ -0,0 +1,125 @@ +--- +name: python-event-streams +description: Consuming a server-sent-events (text/event-stream) operation on an APIMatic-generated Python SDK — what EventStream[T] / AsyncEventStream[T] yields, how the loop must end so the connection is released, narrowing a union of event types, what raises and when (there is no SSE exception class), the silent failures, and bounding the wait. Load before writing the loop over an operation that returns an event stream, in addition to `python-calling-endpoints`. +--- + +# Event streams on an APIMatic Python SDK + +> Throughout this skill, `{...}` is a placeholder for a name you take from your SDK (e.g. `{group}`, +> `{operation}`) — replace it with the concrete identifier from the source. + +An operation whose success response is `text/event-stream` returns an `EventStream[T]` +(`AsyncEventStream[T]` on the async client) rather than a value. When the call returns, the request has +gone out and the response head has arrived — what is deferred is the **body**, and the stream holds one +pooled connection until it is released. + +```python +EventStream[T] # sync client -- `for x in stream`, `with`, `close()` +AsyncEventStream[T] # async client -- `async for x in stream`, `async with`, `await aclose()` +``` + +Iterating yields `T`, each event's decoded `data`. The `event`, `id` and `retry` fields are parsed and +dropped — there is no frame object. Where the operation declares a terminating sentinel (such as +`[DONE]`), the SDK consumes it, closes the stream and never yields it. + +## Start here + +**Drain the loop.** Exhaustion closes the connection, so the shortest correct code is a plain `for`: + +```python +for event in client.{group}.{operation}(...): + handle(event) +``` + +**Reach for `with` the moment you might stop early** — every `break`, every `return` out of the loop, +every exception you catch outside it: + +```python +with client.{group}.{operation}(...) as stream: + for event in stream: + if done(event): + break # the `with` releases the connection +``` + +## How the iteration ends + +| You want | Write | Releases | +|---|---|---| +| the whole stream | a plain `for` | on exhaustion | +| to stop at a condition | `with` + `break` / `return` | the `with` | +| a bounded prefix | `with` + your own counter | the `with` | +| a stream that outlives one block | `try` / `finally` + `close()` | your `close()` | +| — | **abandon it part-way** | the default transport, once the abandoned iterator is finalized; a custom one may hold it until the client closes — a `ResourceWarning` either way | + +Closing is idempotent, so a `finally` after exhaustion is safe. A closed stream is **single-use**: +iterating it again raises `ValueError` at the `iter()` call, not at the first `next()`. + +## What the loop yields + +| The success schema is | You get | Narrow with | +|---|---|---| +| a string or another scalar | `EventStream[str]`, `EventStream[int]`, … | nothing — it is the value | +| an object schema | `EventStream[{Model}]` | nothing — it is the model | +| a `oneOf` with a discriminator | `EventStream[{Union}]` | `match` on the arm's class, or `isinstance` | + +A union arrives **already routed** by its wire discriminator, so narrowing is a check on the Python +type, never on a `type` string. + +⚠️ An `OptionalNullable[T]` member is a **tri-state** (`T | None | UnsetType`), and both `None` and +`UNSET` are falsy. `x or ` narrows to `T`, and so does `isinstance(x, T)`; `if x is not None` +does **not** — it leaves `T | UnsetType`. + +## What can go wrong + +| What failed | Parsed call | `with_raw_response` | +|---|---|---| +| a non-2xx status (the head) | raises `ApiError` at the call | returns `Failure` — no raise | +| a frame the event type rejects | raises pydantic's `ValidationError` from the loop, **after** the stream closed | same | +| an event over 1 MiB, or bad UTF-8 | raises `ValueError` / `UnicodeDecodeError` from the loop | same | +| the wire stalls | raises `httpx2.ReadTimeout` from the loop | same | +| the connection drops mid-stream | raises an `httpx2.RequestError` subclass from the loop, such as `RemoteProtocolError` or `ReadError` | same | +| **the connection closes a body with no `Content-Length` that is not chunked** | **nothing — the loop ends early** | same | +| **the body is not an event stream** | **nothing — the loop ends with zero events** | same | + +**There is no SSE exception class.** Everything the loop raises for a bad body is already a +`ValueError`, so one `except ValueError` covers the family. A stalled or dropped connection is not a +bad body: it raises `httpx2.RequestError` (`ReadTimeout` included), so catch that too. Put both +boundaries around the **loop**, not only the call — the call returns as soon as the head arrives. + +The last row is a known gap: the SDK does not check `Content-Type`, so a 200 carrying a proxy's HTML +page or a buffered JSON body parses as a stream of zero events and ends quietly. If a stream yields +nothing, read the raw peer's `Success(...).headers` before suspecting the parser. + +## Bounding the wait + +`request_options={"timeout": ...}` bounds **each read**, so it limits the gap *between* events, not the +stream's total length — a legitimate stream may run for minutes. For a total budget, async callers wrap +the consumer in `asyncio.wait_for`; sync callers have no equivalent and must consume in a thread they +can abandon. + +⚠️ `asyncio.wait_for`, not `async with asyncio.timeout(...)` — the latter is **3.11+**, and this SDK +supports 3.10. + +## The async client + +The `await` is on the **call**, not on the iteration: + +```python +async for event in await async_client.{group}.{operation}(...): + handle(event) + +async with await async_client.{group}.{operation}(...) as stream: + async for event in stream: + if done(event): + break +``` + +`async with` matters *more* than `with` does on the sync client: an abandoned async stream is not +finalized by reference counting, so its `ResourceWarning` arrives later and further from the cause. + +## Testing an event stream + +Stub the transport (`python-testing`) and hand the SDK a response whose `iter_bytes(None)` yields +event frames — `b"data: {...}\n\n"`, one blank line ending each event. The SDK reads the body in the +transport's own chunks, so split a frame across two chunks to prove the loop does not depend on +where the network cuts. diff --git a/plugins/alpaca/skills/python/python-file-handling/SKILL.md b/plugins/alpaca/skills/python/python-file-handling/SKILL.md new file mode 100644 index 000000000..df25a80e3 --- /dev/null +++ b/plugins/alpaca/skills/python/python-file-handling/SKILL.md @@ -0,0 +1,190 @@ +--- +name: python-file-handling +description: Sending and receiving files with an APIMatic-generated Python SDK — which arm of the upload alias to hand an endpoint and what each one costs in memory, re-sendability and ownership, the three behaviours that differ between a multipart part and a raw body, when a body goes out chunked rather than sized, overriding a filename or media type, and consuming a streamed file response without leaking its connection. Load before passing a file to an operation or consuming one it returns, in addition to `python-calling-endpoints`. +--- + +# File handling on an APIMatic Python SDK + +**One name per client, at every position.** A multipart file part and a raw binary body accept the +same alias, and only the client you built decides which: + +```python +BinaryContent = bytes | bytearray | Path | BinaryReader | Iterator[bytes] +AsyncBinaryContent = BinaryContent | AsyncBinaryReader | AsyncIterable[bytes] + +FileInput = BinaryContent | NamedFile[BinaryContent] # sync client +AsyncFileInput = AsyncBinaryContent | NamedFile[AsyncBinaryContent] # async client +``` + +So you make exactly one choice: **where the bytes come from**. The destination is fixed by the +endpoint, and the flavour by the client. All of these are imported from `{root_package}.core`, and +`list[FileInput]` is an array part. + +Nothing validates what you pass, so a wrong arm is a type error at build time and a wrong *value* is +a failure at send time. What each arm accepts is wider than the annotation suggests. + +## Start here + +**Use `Path`.** It streams rather than loading the file, the same value can be sent twice, the SDK +closes what it opened, it names the file on the wire for you, it keeps a `Content-Length`, and it is +the only arm that behaves identically at both destinations. + +```python +from pathlib import Path + +client.{group}.{operation}(Path("invoice.pdf")) +``` + +Every other arm below exists for a reason you will know you have. + +## Which arm + +| Arm | Reach for it when | What it costs | +|---|---|---| +| `Path` | the bytes are a file — **the default** | nothing worth naming | +| `bytes` / `bytearray` | small, or built in memory with no file behind it | fully resident; carries no filename | +| `BinaryReader` — any object with a binary `read` | a handle that is not a path: `gzip`, a spool, another library's | you close it; not re-sendable | +| `Iterator[bytes]` | transforming on the way out, or you want a progress hook | single-use; makes a whole multipart body chunked | +| `AsyncBinaryReader` — awaitable `read` | you already hold an `anyio`-style handle *(async client only)* | buffered through a temporary file in a multipart part | +| `AsyncIterable[bytes]` | relaying bytes that arrive asynchronously *(async client only)* | the same | +| `NamedFile[...]` | the derived filename or media type is wrong, or the source has none | nothing — it is an override, not an arm | + +A `bytearray` is the `bytes` arm, copied on the way through, so mutating yours after the call cannot +change what was sent. + +## Three behaviours that differ by destination, not by type + +These are the only places the position still matters, and none of them is visible in the annotation. + +1. **A handle you pass mid-file is rewound in a multipart part and is not in a raw body.** So a + position you set is discarded in a part and honoured in a raw body, and the same handle is + re-sendable in one place and not the other. +2. **A chunk iterator makes the whole multipart body chunked.** One part that cannot report a length + stops every part being sized. As a raw body it only chunks itself. +3. **An async source in a multipart part is buffered through a temporary file first.** Nothing + accumulates in memory and the part keeps its length, but the payload is briefly written to disk: + it needs room in the temp directory, and a read-only or undersized one raises `OSError`. Yield + windows rather than one whole `bytes` from an `AsyncIterable` — your pieces are forwarded as you + yield them, so a single large `bytes` is resident before the SDK ever sees it. + + So **do not reach for an async handle over a file you could have named.** `anyio.open_file(p)` in + a part costs two reads and a write where `Path(p)` costs one read. The async arms earn their keep + when there is no path — bytes arriving from another connection, a queue, a subprocess — and cost a + round trip through the filesystem when there is one. + +On the **async client**, reading a *sync* arm inside a multipart body occupies the event loop while +it reads, so `bytes` and the async arms are the spellings that never block it there. A raw body has +neither cost: every arm streams without occupying the loop. + +## When the body goes out chunked + +The body is sent with a `Content-Length` **only when the length can be established** — a plain file +handle or an in-memory buffer, sitting at the start. Everything else is chunked, which is correct +rather than merely cautious: for a decompressing reader, or one already read from, the length of the +file behind it is not the length of the body. + +So a `gzip.open(...)` handle uploads fine, chunked. If a server refuses chunked uploads, either pass +the `Path` — which keeps its `Content-Length` — or declare the length yourself: + +```python +client.{group}.{operation}(handle, request_options={"extra_headers": {"content-length": "1000"}}) +``` + +## The filename, and the media type + +**Both are derived, and both are overridable at either destination.** Left alone, the filename comes +from a `Path`'s own name or a handle's `.name`, **reduced to a basename** so your directories never +reach the wire; a source that has no name at all — `bytes`, a `BytesIO`, a chunk iterator — sends +none rather than an invented one. A multipart part is sent under it; a raw body carries it as the +whole request's `Content-Disposition`. + +`NamedFile` is the only override, and it takes the two fields that matter: + +```python +from {root_package}.core import NamedFile + +client.{group}.{operation}(NamedFile(b"...", filename="invoice.pdf", media_type="application/pdf")) +``` + +Set `filename` and it wins over a `Path`'s own name and a handle's `.name` alike, so wrapping a named +source renames it rather than being ignored. + +**Every file currently goes out as `application/octet-stream`** whatever the spec declares, so +`NamedFile(content, media_type=...)` is the only way to send anything else. If the server validates a +part's `Content-Type`, say the type yourself. + +## There is no `str` arm + +A bare string is unresolvably either a path or the content itself, so `mypy --strict` rejects it. At +run time the failure differs by position, and neither is guided: + +- a **multipart part** raises `AttributeError: 'str' object has no attribute 'read'` from inside the + send +- a **raw body** sends the string's own bytes, so `client.{group}.{operation}("invoice.pdf")` uploads + eleven bytes and reports success + +Wrap a path in `Path`. + +## Who closes what + +**The SDK closes only what it opened.** A `Path` is opened for the send and closed when it returns or +raises — never later. A handle **you** open stays yours to close, and is never closed behind your +back. An iterable of chunks has no position at all: it is consumed once, at either destination, and +cannot be re-sent. + +## Reading a file response + +A `FileResponse` — `AsyncFileResponse` on the async client — is a body that has **not been read yet** +and holds its connection until it is. That makes it the one return value with a lifecycle, and the +three ways to consume it all close it: + +```python +client.{group}.{operation}(...).save_to("out.bin") # streams to disk +data = client.{group}.{operation}(...).read() # buffers — for a payload that fits in memory +with client.{group}.{operation}(...) as f: # anything else + for chunk in f.iter_bytes(): + ... +``` + +Both iterators take a `chunk_size`, defaulting to 64 KiB. + +- **Reading is what closes it**, so only a *partially* consumed `iter_bytes()` outside a `with` leaks. + That case emits a `ResourceWarning` at finalization — a category Python's default filters silence, + so enable it (`-W error::ResourceWarning` plus `filterwarnings` in your pytest config; under pytest + the flag alone is a no-op) if you want the leak to fail a test. By then the default transport has + already released the connection, as soon as the abandoned iterator was finalized; a custom one may + not, so the client's own `close()` stays the backstop, since it disposes the pool and any + connection still held. +- **Single-use.** Once closed, reading again raises, and one consumer means one pass: `read()` after a + partially driven `iter_bytes()` raises too. +- **The async twin is `AsyncFileResponse`** — same lifecycle, mostly `a`-prefixed verbs: `await + f.aread()`, `async for chunk in f.aiter_bytes()`, `await f.aclose()`, and `async with` as the + spelling to reach for. **`save_to` is the exception**: it keeps its name and is awaited — `await + f.save_to("out.bin")` — so there is no `asave_to`. Calling it without `await` creates a coroutine + and drops it: nothing is written and the connection stays held. An abandoned response defers its + warning to loop shutdown, where it is far less useful — and that warning is silenced by default + too. +- **`save_to` accepts a directory**, naming the file from the server's `Content-Disposition` — reduced + to a safe basename first, because that header is attacker-controlled — and raising `ValueError` when + the server sent no filename, or sent one that reduces to nothing safe to create: empty, `.` or + `..`, a reserved device name (`CON`, `NUL`, `COM1`…), or longer than 255 characters. It writes + `.part` and moves it into place atomically, so an interrupted download never appears under + the final name. If the destination cannot be opened at all — a missing parent directory, or + permissions — the response is still consumable. +- **The head has already arrived**, so `headers`, `media_type`, `content_length` and `filename` are + readable before you read a byte. `filename` is the server's suggestion and may be `None`. +- **`request_options={"timeout": ...}` bounds each read of the body**, not the call as a whole, so a + stalled download raises rather than hanging. +- **A transport failure mid-download raises out of the *read*, not out of the call.** The call already + returned successfully — only the head had arrived — so a `try` wrapped around the call alone catches + nothing. Put the boundary around the consuming code (`python-error-handling`). + +With `with_raw_response`, the payload is the same unread `FileResponse`. The raw peer buys you the +status code and headers, not the bytes — a result carries no body of its own, because here the body +*is* the payload. + +## Where these types live + +Import all of them from `{root_package}.core`: the two aliases and their content unions, `NamedFile`, +the two reader protocols, and the two response classes. They are not re-exported from +`{root_package}/__init__.py`, so the package root is not where to look for them. diff --git a/plugins/alpaca/skills/python/python-getting-started/SKILL.md b/plugins/alpaca/skills/python/python-getting-started/SKILL.md index 7dc4b08e7..b8b5c08be 100644 --- a/plugins/alpaca/skills/python/python-getting-started/SKILL.md +++ b/plugins/alpaca/skills/python/python-getting-started/SKILL.md @@ -1,11 +1,11 @@ --- name: "python-getting-started" -description: "Trader API Python SDK identity and lookup layer (Python only) — install, import root, base URL/environments, the auth pattern, the SDK map that ships at the SDK root (`sdk-map.md` + `map/operations/`) and how to traverse it, and the module table naming the one file owning each fact the map leaves to the source. Load this before answering any Trader API Python SDK contract question or writing any SDK code." +description: "Alpaca Python SDK identity and lookup layer (Python only) — install, import root, base URL/environments, the auth pattern, the SDK map that ships at the SDK root (`sdk-map.md` + `map/operations/`) and how to traverse it, and the module table naming the one file owning each fact the map leaves to the source. Load this before answering any Alpaca Python SDK contract question or writing any SDK code." --- -# Getting started with the Trader API Python SDK +# Getting started with the Alpaca Python SDK -> **Who this skill is for.** This is the **lookup layer** for anyone writing Trader API Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-alpaca` for the workflow that wraps this skill. +> **Who this skill is for.** This is the **lookup layer** for anyone writing Alpaca Python SDK code — it is yours to follow directly and fully. Ground every contract fact here (in the SDK map, and in the source modules it names) rather than in recall, and carry those facts onto a contract sheet before you implement. Load `python-integrate-alpaca` for the workflow that wraps this skill. This is the **SDK-specific** entry point. For general patterns that apply to any APIMatic-generated Python SDK (client construction, auth, calling endpoints, models, error handling, resilience, testing), see the companion API-agnostic skills: `python-client-initialization`, `python-authentication`, `python-calling-endpoints`, `python-models`, `python-error-handling`, `python-configuration-resilience` and `python-testing`. @@ -13,24 +13,24 @@ This is the **SDK-specific** entry point. For general patterns that apply to any ## SDK identity -Verified against `trader_api/` and `pyproject.toml` of the generated package at version `2.0.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. +Verified against `alpaca/` and `pyproject.toml` of the generated package at version `2.0.0`. **Re-verify after a version bump** — this page is a snapshot, not a live read. | Fact | Value | | --- | --- | -| API | Trader API | -| Distribution name (what you install) | `trader-api` — **not on any package index**; installed from source (see *Install*) | -| Import root (what you import) | `trader_api` — not the string you install | +| API | Alpaca | +| Distribution name (what you install) | `alpaca` — **not on any package index**; installed from source (see *Install*) | +| Import root (what you import) | `alpaca` — the same string you install | | Source repository | https://github.com/context-plugins/alpaca-python-sdk | | Source branch | `main` | | Version | `2.0.0` | -| Sync client class | `TraderApiClient` (alias `Client`) | -| Async client class | `AsyncTraderApiClient` (alias `AsyncClient`) | -| Client construction | **keyword-only**: `environment` · `base_url` · `timeout` (default `30.0`) · `api_key` · `api_secret`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | +| Sync client class | `AlpacaClient` (alias `Client`) | +| Async client class | `AsyncAlpacaClient` (alias `AsyncClient`) | +| Client construction | **keyword-only**: `environment` · `base_url` · `timeout` (default `30.0`) · `retry_options` · `api_key` · `api_secret`, and the transport override — `custom_http_client` on the sync client, `custom_async_http_client` on the async one (the names differ; see step 1) | | Auth | **API key** in the `APCA-API-KEY-ID` header — set `api_key` · **API key** in the `APCA-API-SECRET-KEY` header — set `api_secret` | | Environments | 2 environments selected by `environment` (default `"paper"`), overridable with `base_url` | -| Base-URL config | `ServerConfig` (`trader_api/server/server_config.py`), frozen, `extra="forbid"` | +| Base-URL config | `ServerConfig` (`alpaca/server/server_config.py`), frozen, `extra="forbid"` | | Python floor | **`>=3.10`** (classifiers list `3.10–3.14`) | -| Runtime dependencies | `httpx (>=0.28.1,<1.0.0)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | +| Runtime dependencies | `httpx2 (>=2.13.1,<3.0.0)` · `certifi (>=2024.7.4)` · `pydantic[email] (>=2.11.0,<3.0.0)` · `typing-extensions (>=4.13.0,<5.0.0)` | | Typing | ships `py.typed`; the package is checked under `mypy --strict` with `warn_unreachable`. Callers get full inference — **a type error against this SDK is a real contract violation, not noise** | | Line length / lint | `ruff`, 120 cols (only relevant when editing the SDK itself) | | Surface | 29 operations across 9 controllers · 16 models · 2 unions · 14 enums · 4 per-operation error unions | @@ -39,28 +39,33 @@ The table above is **orientation, not a copy-paste recipe** — it gives you the ## Install — from source -This SDK is not published to a package index, so there is no `pip install` from PyPI for it. Install it from its repository — — into the same environment your project runs in: +Into the same environment your project runs in, install the distribution from its repository, — it is not published to a package index: ```bash -pip install "trader-api @ git+https://github.com/context-plugins/alpaca-python-sdk.git@main" +pip install "alpaca @ git+https://github.com/context-plugins/alpaca-python-sdk.git@main" +python -c "import alpaca, pathlib; print(pathlib.Path(alpaca.__file__).parent)" ``` -The generated distribution carries its own `pyproject.toml`, so `pip` builds and installs it exactly like a released package. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at the clone's path, so deleting the clone breaks every import. Once installed, write the imports from the table above: the distribution name you install and the package name you import are not the same string. Requires Python 3.10 or newer. +**Run the second line too.** A `pip install` that reports success is not proof the SDK is importable where your project runs. If the probe raises `ModuleNotFoundError`, the install went into a different interpreter: re-run it through the one your project uses (`python -m pip install …`) and probe again. If it prints a path whose contents are not this SDK, what you installed is not this distribution. + +The distribution carries its own `pyproject.toml`, so `pip` builds and installs it like any released package, copied into site-packages. Do not vendor its source into your project, add its directory to `sys.path`, or install it editable (`-e`) from a throwaway clone — an editable install points at that directory, so deleting the clone breaks every import. + +Once the second line prints this SDK's directory, write the imports from the table above: the distribution name you install and the package name you import are the same string. Requires Python 3.10 or newer. ## Imports — the package splits its surface across four modules -Python does not re-export child modules transitively, so `from trader_api import models` alone does **not** make enums, error unions, or runtime types reachable. Import each kind of type from the module that owns it. +Python does not re-export child modules transitively, so `from alpaca import models` alone does **not** make enums, error unions, or runtime types reachable. Import each kind of type from the module that owns it. -`trader_api/__init__.py` exports exactly 6 names beside the `trader_api.models` subpackage it re-exports: +`alpaca/__init__.py` exports exactly 6 names beside the `alpaca.models` subpackage it re-exports: ```python -from trader_api import ( +from alpaca import ( + AlpacaClient, + AsyncAlpacaClient, AsyncClient, - AsyncTraderApiClient, Client, Environment, ServerConfig, - TraderApiClient, ) ``` @@ -68,12 +73,12 @@ Everything else comes from its own subpackage, and the split matters because eac | What you need | Where it lives | | --- | --- | -| Domain models, their `…Dict` companions | `trader_api.models` | -| Enums (and their open `…OrStr` aliases) | `trader_api.models.enums` | -| `ApiError` · `RawError` · `ApiResult` · `RequestOptions` · `HttpClient` · `SdkBaseModel` · `UNSET` · `Optional` | `trader_api.core` | -| Per-operation error *unions* | `trader_api.errors` (`DeleteAllOpenPositionsErrorBody`, …) | +| Domain models, their `…Dict` companions | `alpaca.models` | +| Enums (and their open `…OrStr` aliases) | `alpaca.models.enums` | +| `ApiError` · `RawError` · `ApiResult` · `RequestOptions` · `HttpClient` · `SdkBaseModel` · `UNSET` · `Optional` | `alpaca.core` | +| Per-operation error *unions* | `alpaca.errors` (`DeleteAllOpenPositionsErrorBody`, …) | -`trader_api.core` re-exports its whole public surface (a curated `__all__`), so import from `…core` rather than from the private modules beneath it (`…core.results`, `…core.exceptions`, `…core.auth.schemes`). +`alpaca.core` re-exports its whole public surface (a curated `__all__`), so import from `…core` rather than from the private modules beneath it (`…core.results`, `…core.exceptions`, `…core.auth.schemes`). ## Environments @@ -96,7 +101,7 @@ Consequences to state on every contract sheet that touches configuration: An API key sent as the `APCA-API-KEY-ID` header, exposed as the client's `api_key=` keyword taking a plain string. ```python -from trader_api import Client +from alpaca import Client client = Client(api_key="") ``` @@ -104,7 +109,7 @@ client = Client(api_key="") An API key sent as the `APCA-API-SECRET-KEY` header, exposed as the client's `api_secret=` keyword taking a plain string. ```python -from trader_api import Client +from alpaca import Client client = Client(api_secret="") ``` @@ -135,24 +140,24 @@ The SDK map's controller table carries the same counts and links to a page per c ## SDK map — look up first, open the module second -The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `trader_api/` source directory, and these two entries: +The SDK ships a generated map at its **root** — the directory holding `pyproject.toml`, the `alpaca/` source directory, and these two entries: - **`sdk-map.md`** — the index: client construction with the full constructor-keyword table, the error-handling model (`ApiError` / `ApiResult` / `RawError`, Case A vs Case B), where models, enums and error aliases live, servers and auth, and the link table into the operations pages. -- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every type the operation mentions. +- **`map/operations/.md`** — one page per controller, one `###` block per operation: the HTTP verb and route, the sync parsed signature, each parameter's role and wire name, both return types, the error alias with the status each arm maps from, and a **Type sources** table naming the module that declares every *generated* type the operation mentions. -**Installing the distribution does not give you the map.** `pyproject.toml` ships the `trader_api/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, . One clone therefore brings the map and the code it describes together, in lockstep by construction. Clone it to a temporary directory, outside the project repo: +**Installing the distribution does not give you the map.** `pyproject.toml` ships the `alpaca/` package only, so `sdk-map.md` and `map/operations/` are absent from the installed package — they live in the SDK's own source tree, at the root of its repository, , in lockstep with the code the map describes — clone it to a temporary directory, outside the project repo: ```bash git clone --depth 1 --branch main https://github.com/context-plugins/alpaca-python-sdk ``` -Keep the `--branch main`: it is the branch this SDK is released from, and the repository's default branch may carry a different version — a map read from the wrong branch describes code you do not have. +Confirm the map comes from the release you installed before you read anything out of it: the clone above is pinned to `main`, the branch this SDK is released from — the repository's default branch may carry a different version. A map read from any other release describes code you do not have. -Every `Source` path on the map is relative to that SDK root, so `trader_api/models/account.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. +Every `Source` path on the map is relative to that SDK root, so `alpaca/models/account.py` opens as written from there — and the same path resolves inside the installed package, which is where you read a module's body once the map has named it. **The map is the locator; the source modules are the shapes.** Read the map first — signatures, routes, parameter roles, return types, error unions, and which module declares a type are all answered there without opening a single `.py` file. Then open the one module the map names for what it deliberately does not carry: a model's members, an enum's values, a field's wire alias. The map says so itself — *"Shapes live only in the source … Never grep for a type."* -**The map carries shapes; what an operation *means* lives elsewhere.** When *what* to pass depends on meaning — which values a field accepts beyond its type, a rule that couples two fields, what a defaulted parameter actually selects — the map will not settle it. Open the operation's docstring in `trader_api/apis/.py` (or `client.py` where operations sit on the client) and `api-reference.md` at the SDK root for that operation *before* writing the sheet row, and record what you found there. A value you already "know" for a field the map types as a plain `str` is a lookup, not a recall — the memory ban applies to it. +**The map carries shapes; what an operation *means* lives elsewhere.** When *what* to pass depends on meaning — which values a field accepts beyond its type, a rule that couples two fields, what a defaulted parameter actually selects — the map will not settle it. Open the operation's docstring in `alpaca/apis/.py` (or `client.py` where operations sit on the client) and `api-reference.md` at the SDK root for that operation *before* writing the sheet row, and record what you found there. A value you already "know" for a field the map types as a plain `str` is a lookup, not a recall — the memory ban applies to it. `sdk-map.md` carries the invariants every operation block assumes, so load it before any `map/operations/` page; the pages are written to be read beside it. @@ -163,10 +168,10 @@ Every `Source` path on the map is relative to that SDK root, so `trader_api/mode Read the one module that owns the fact **inside the installed package**. Locate it first: ```bash -python -c "import trader_api, pathlib; print(pathlib.Path(trader_api.__file__).parent)" +python -c "import alpaca, pathlib; print(pathlib.Path(alpaca.__file__).parent)" ``` -Failing that, it is under the project's environment (`.venv/Lib/site-packages/trader_api` on Windows, `.venv/lib/python3.*/site-packages/trader_api` elsewhere). **If the package is not installed, there is no source to read** — mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: +Failing that, it is under the project's environment (`.venv/Lib/site-packages/alpaca` on Windows, `.venv/lib/python3.*/site-packages/alpaca` elsewhere). **If the package is not installed, install it first** — see *Install* above; until then mark the fact `UNVERIFIED` and say what would settle it rather than answering from memory. Paths below are relative to that package root: | Question | Module | | --- | --- | @@ -174,7 +179,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/tr | Client construction, keywords, controller wiring | `client.py`, `async_client.py`, `base_client.py` | | Timeout default and validation | `base_client.py` (`DEFAULT_TIMEOUT = 30.0`) | | The request/response pipeline, 401 handling, 2xx-vs-error split | `core/raw_client.py` | -| Exception shape (`ApiError.error`, `.response`, `.status_code`) | `core/exceptions.py` | +| Exception shape (`ApiError.error`, `.status_code`, `.headers`) | `core/exceptions.py` | | `Success`/`Failure`/`RawError` | `core/results.py` | | Per-call overrides | `core/request_options.py` | | `UNSET`, `Optional`, `OptionalNullable` | `core/optionality.py` | @@ -184,7 +189,7 @@ Failing that, it is under the project's environment (`.venv/Lib/site-packages/tr | Open-enum coercion | `core/converters/open_enum.py` | | Date/time wire formats — `Date`, `RFC3339DateTime`, `RFC1123DateTime`, `UnixSecondsDateTime` (`Annotated` aliases over `datetime.date` / `datetime.datetime`; not in the map's Type sources) | `core/converters/date_time.py` | | Transport protocols (the test seam) | `core/transport.py` | -| httpx adapter, proxy/TLS knobs | `core/httpx_transport.py` | +| httpx2 adapter, proxy/TLS knobs | `core/httpx2_transport.py` | | Token fetch, credential placement | `core/auth/`, `core/auth/models.py` | | Base-URL resolution | `server/server_config.py`, `server/server.py` | | An operation's error mapper (status → schema) | `errors/_error.py` | @@ -201,13 +206,13 @@ Keep lookups cheap — the rules that keep a session's context small: Before you write the code for each step, load the named companion skill — even if you have already read the relevant module. Each step calls out the trap the signature hides (in *parens*). A typical integration reaches them in this order: -1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) +1. **Client construction & lifetime** — load **python-client-initialization** before you write `Client(...)` or `AsyncClient(...)`. (*The signature won't tell you:* the constructor is keyword-only, so nothing can be passed positionally; the client owns an `httpx2` connection pool and you **must** `close()` (sync) or `await aclose()` (async) or use it as a context manager; it must be long-lived and module- or app-scoped, never rebuilt per request; the sync and async clients do not mix; and the transport-override keyword differs by client — `custom_http_client` vs `custom_async_http_client`.) 2. **Authentication** — load **python-authentication** before you set credentials. The two schemes are `api_key=` and `api_secret=`. (*The signature won't tell you:* every credentials keyword is *optional* — omit it and every request goes out unauthenticated, with no failure at construction and not necessarily a `401` to tell you; Load secrets from the environment or a secret store, never hardcode.) 3. **Calling an endpoint** — load **python-calling-endpoints** before the first `client..(...)` call. (*The signature won't tell you:* every operation splits positional path params (and sometimes the body) from a keyword-only tail after `*`; every keyword-only parameter has a **real** default, so there is no "must pass `None` explicitly" hazard; **3 operations return `None`**, so `with_raw_response` is the only way to observe their status code; and the two response modes — raising vs `ApiResult` — behave differently on failure.) 4. **Models** — load **python-models** the moment a request/response member is not a plain string or number. (*The signature won't tell you:* `Optional[T]` here is `T | UnsetType`, **not** `typing.Optional` — `None` is not a legal value for it; models are frozen pydantic instances with `…Dict` TypedDict companions; enums are **open** (`…OrStr`), so an unknown wire value passes through as a plain `str` rather than raising; wire aliases differ from Python member names; unknown response fields are **preserved**, not dropped; and serialize via `to_dict`/`to_json`.) -5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx` transport exceptions reach your boundary unwrapped.) -6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, proxies, TLS, or logging. (*The signature won't tell you:* **the SDK performs no retries at all** — retry/backoff is entirely yours to build or deliberately omit; `timeout` defaults to `30.0` and is a single float that maps onto `httpx`'s timeout semantics rather than bounding the whole call; and there is no logging hook — you wrap the transport seam.) -7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client`, or `respx` at the `httpx` layer — not the client class; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) +5. **Error handling** — load **python-error-handling** before you write any `try/except`. (*The signature won't tell you:* there is a single `ApiError` type whose `.error` is a **per-operation union**, and a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes; `httpx2` transport exceptions reach your boundary unwrapped.) +6. **Configuration & resilience** — load **python-configuration-resilience** when you set the base URL, timeouts, retries, proxies, TLS, or logging. (*The signature won't tell you:* **retrying is on by default** — `retry_options` takes a count, a `RetryOptions` or its dict, `max_retries` set to `0` turns it off, and one call overrides it through `request_options`; a `401` is never retried; `timeout` defaults to `30.0` and is a single float that maps onto `httpx2`'s timeout semantics, limiting each wait rather than the whole call; and there is no logging hook — you wrap the transport seam.) +7. **Testing** — load **python-testing** before you stub the SDK. (*The signature won't tell you:* the seam is the **transport protocol** (`HttpClient`/`AsyncHttpClient` in `core/transport.py`) passed as `custom_http_client` — not the client class; a fake transport implements `send` and `close`, the two the protocol declares, and `send` answers with the head alone; assert on the request the SDK actually built, and cover all four failure kinds, decode failures included.) ## What a contract sheet must carry for this SDK @@ -218,8 +223,9 @@ Beyond the usual signatures and model members, a Python sheet is incomplete with 3. **The 3 operations that return `None`** — `orders.delete_order_by_order_id` · `watchlists.delete_watchlist_by_id` · `watchlists.delete_watchlist_by_name`. Their raw peers are `ApiResult[None, …]`, so `with_raw_response` is the only way to observe the status code. 4. **Required vs `UNSET`** for every model member the task sets, and the fact that `Optional[T]` here is `T | UnsetType` — **not** `typing.Optional`, so `None` is not a legal value for it. 5. **The `ApiError.error` union** for each operation in scope — no operation in this SDK documents a typed error body, so `.error` is always `RawError`. -6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `_build_result`'s own docstring. **And that the 2xx path declares at least one required member on 19 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. -7. **That the SDK performs no retries at all**, so retry/backoff is the caller's to build or deliberately omit. -8. **Which environment the `environment` keyword selects**, because omitting it is silently `"paper"` of the 2 declared. -9. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. +6. **That a decode failure raises `ValidationError`/`ValueError`, not `ApiError`, in both response modes** — `core/raw_client.py` states this in `execute`'s own docstring. **And that the 2xx path declares at least one required member on 19 return types, so a truncated body fails to decode there and passes silently everywhere else.** Any sheet row for a call whose result is used must name the members the implementer has to assert on. +7. **That retrying is on by default** — `retry_options` on the client tunes it, `max_retries` set to `0` turns it off, and `request_options` overrides it per call — so the sheet says whether this integration keeps, tunes or disables it, and for which methods, since a write is repeated only when the policy names its method. +8. **What limits a whole call.** `timeout` limits each wait, not the call, so a reply whose pieces each arrive within it is never cut off. In async code, state the budget and the one place `asyncio.wait_for` enforces it for every call, and what the boundary returns for its `asyncio.TimeoutError`; in sync code, say there is none. +9. **Which environment the `environment` keyword selects**, because omitting it is silently `"paper"` of the 2 declared. +10. A **REQUIRED READING** block naming the `python-*` companions that govern the steps, with `MUST load` pointers. diff --git a/plugins/alpaca/skills/python/python-integrate-alpaca/SKILL.md b/plugins/alpaca/skills/python/python-integrate-alpaca/SKILL.md index c23803d04..76852ad32 100644 --- a/plugins/alpaca/skills/python/python-integrate-alpaca/SKILL.md +++ b/plugins/alpaca/skills/python/python-integrate-alpaca/SKILL.md @@ -1,9 +1,9 @@ --- name: "python-integrate-alpaca" -description: "MANDATORY FIRST STEP for Trader API Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Trader API in Python — gets the current account configuration values, returns the account associated with the api key, returns the market calendar, retrieves a single order for the given order_id, returns a watchlist identified by the id, returns a watchlist by name, delete one entry for an asset by symbol name, update the name and/or content of watchlist — or when a Trader API Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." +description: "MANDATORY FIRST STEP for Alpaca Python SDK work in a Python project — load this BEFORE you write any code; Python SDK ONLY, never load it for any other language. Applies when asked to integrate Alpaca in Python — gets the current account configuration values, returns the account associated with the api key, returns the market calendar, retrieves a single order for the given order_id, returns a watchlist identified by the id, returns a watchlist by name, delete one entry for an asset by symbol name, update the name and/or content of watchlist — or when a Alpaca Python SDK call errors or behaves unexpectedly. Knowing the SDK exists is NOT a substitute for loading this, because it carries five binding gates stated NOWHERE else and not inferable from the package — (1) load python-getting-started, confirm the package is actually installed and read the SDK map that ships with it before any lookup, (2) the exact plan-file path and the no-project-file-edits window until a contract sheet with no open lookups exists there and you have read it, (3) the mandatory load of every python-* companion skill the sheet names, (4) sync-vs-async decided once from the host application before the first call, and (5) the memory ban, where every signature, wire alias, error union and enum member comes from a lookup and never from recall or runtime introspection." --- -# Trader API Python SDK — Integration workflow (lookup layer + contract sheet) +# Alpaca Python SDK — Integration workflow (lookup layer + contract sheet) You do the SDK lookups yourself: `python-getting-started` is your **lookup layer** — load it and ground every contract fact in it (and in the source modules its table names) before writing code. The `python-*` companion skills are a different thing: they are API-agnostic *usage* guidance, they are yours to load, and Step 1c below makes loading them mandatory. @@ -11,24 +11,24 @@ You do the SDK lookups yourself: `python-getting-started` is your **lookup layer - **`python-getting-started`** is the entry point for every SDK need. It carries the SDK's identity (distribution name vs import root, version, Python floor, install-from-source), what the root package exports and the four modules the surface splits across, environments and the base-URL knob, the auth pattern, the controllers, and a **module table** naming the one file that owns each kind of fact. Load it first, always. - **The SDK map is your lookup surface, and you open it yourself.** The SDK ships `sdk-map.md` and `map/operations/` at its root. There is no helper agent on the Python side — the map is yours to read, not something to delegate. `python-getting-started` tells you how to traverse it: read `sdk-map.md` once, then the one controller page your operations live on. -- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import trader_api, pathlib; print(pathlib.Path(trader_api.__file__).parent)"`. **If it is not installed there is no source to read**: mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) +- **The installed package is the ground truth; the module table is only the locator.** Confirm the package is importable before you rely on a lookup — `python -c "import alpaca, pathlib; print(pathlib.Path(alpaca.__file__).parent)"`. **If it is not installed, install it first** — `python-getting-started` carries the command; until then mark the fact `UNVERIFIED`, say what would settle it, and do not fill the hole from memory. (The distribution is not on any package index — it installs from its repository; `python-getting-started` carries the command.) - **Read scoped.** Those modules carry long design docstrings. `grep -n` for the symbol and read the surrounding lines rather than whole files, and never copy a docstring's design rationale onto a contract sheet — the sheet carries facts an implementer must obey, not the reasoning behind them. - **Write a contract sheet with no open lookups** before you implement: exact signatures, the keyword-only boundary, wire aliases, required-vs-`UNSET` members, the `ApiError.error` union per operation, and enum members for the operations in scope. `python-getting-started` ends with a checklist of the rows a Python sheet is incomplete without — treat that list as the checklist for your own sheet, and collect every in-scope operation in ONE pass rather than re-opening a module per member. -**Scope guard:** the APIMatic-generated Trader API **Python SDK** (import root `trader_api`, distribution `trader-api`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. +**Scope guard:** the APIMatic-generated Alpaca **Python SDK** (import root `alpaca`, distribution `alpaca`) in **Python projects only**. Unrelated API, or any language other than Python — do nothing; this skill does not apply. ## Workflow -**If the user opens with a reported SDK error or unexpected Trader API behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: +**If the user opens with a reported SDK error or unexpected Alpaca behaviour** (not new feature work), skip the plan-first flow: load `python-getting-started` and `python-error-handling`, look the failing symbol up in the module the table names, and fix from what you find. Otherwise, for implementation work: ### Step 1 — Plan first (always, for any implementation work) -Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/trader-api-plan.md` — that absolute path, not a location you pick later. +Your FIRST action is to load `python-getting-started` and work through the user's full request (all features in scope — one plan covers the whole implementation). Then write the plan and its contract sheet to `/alpaca-plan.md` — that absolute path, not a location you pick later. **Do the read-only prerequisites in the same pass — they need no SDK knowledge and touch no project file:** - the repo survey (read-only exploration of conventions, layering, and **whether the project is sync or `async`**) — capture each convention as *pattern + the ONE exemplar file path to imitate*, NOT inline code snippets: you will read the exemplar at edit time anyway, so a snippet dump gets paid for twice; -- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `trader-api` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; +- **establish the toolchain before you need it**: which environment manager the repo uses (`uv`, `poetry`, `pip` + venv, `pdm`), whether `alpaca` is already a dependency and at what version, and the exact commands that run the project's tests and type checker. Getting this wrong later costs a broken install mid-implementation — and an uninstalled package costs you the lookup layer itself; - a baseline run of the project's checks (`pytest`, `mypy`, `ruff` — whatever it has) on the UNTOUCHED tree, so later failures are attributable to your changes; - credentials/environment verification (per the task's secret-handling rules) — including which host `environment` will select, since omitting it is silent; - **a read-only smoke of each in-scope operation against the real credential**, run from your scratchpad, not the project, once the map has given you the signatures and before you finalise the sheet. A plan-, region- or entitlement-gated endpoint answers `403` here — the map presents every operation uniformly and cannot know which ones your key may call — and learning that now reorders the plan rather than the build. Skip only operations whose side effects you cannot reverse; @@ -36,9 +36,24 @@ Your FIRST action is to load `python-getting-started` and work through the user' Never use the planning phase to get a "head start" on implementation: **creating or editing ANY project file before the gate below is a defect**, no matter how obvious the code seems. -**HARD GATE — no project-file creation or edits until:** `trader-api-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. +**HARD GATE — no project-file creation or edits until:** `alpaca-plan.md` EXISTS at the repo root, its contract sheet has **no open lookups**, and you have read it. The gate bars coding "meanwhile"; it does not bar the read-only prerequisite work above. Starting to code before the sheet exists defeats the entire plan-first design. -**What the gate does not cover:** creating or activating a virtual environment, installing `trader-api` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. +**What the gate does not cover:** creating or activating a virtual environment, installing `alpaca` and the toolchain into it, and the files those writes produce (`.venv/`, a lockfile the installer updates) are **prerequisites, not project-file edits**. On a greenfield repo they are the only way to reach the lookup layer at all, and they are the only writes permitted before the gate. Nothing you author by hand may change. + +**DUPLICATE CLAIMS** — one row per write a caller can trigger twice; `none` if the task has no such write (`python-configuration-resilience`, *The same operation arriving twice*). + +| Write | Where the claim is stored | What rejects the second one | Where that rejection is caught | Where in the code | +| --- | --- | --- | --- | --- | + +Every row follows one order: **claim → SDK call → record the result**. The claim is a write of its own, made before the SDK call, so a second caller is stopped before it reaches the provider; release it when the provider refuses the call. + +⚠⚠ **Where the claim is stored**: a store this application owns — the one the codebase already keeps its records in — never the provider. A provider idempotency key goes beside the claim, never in place of it; the SDK's own `Idempotency-Key` header is not one — it is new on every call. If this codebase has nowhere to keep a claim that outlives the process, that is a Blocker in the plan's **Assumptions & Blockers**, not a row. + +⚠⚠ **What rejects the second one**: the store refusing the second caller's claim, the way this codebase already refuses a duplicate. A conflict on the save *after* the SDK call is too late — both callers have already reached the provider. A read before the write — a status check, a lookup by key — is not a refusal either: it lets both callers through. Pick a refusal the store you run against enforces. + +**Where that rejection is caught**: the `except` for what the store raises on the second claim — a lookup that returns the earlier result is not an `except`. + +Write `TBD` in **Where in the code** while you plan — every row; it names code not yet written, not a contract fact, so it is not an open lookup. Once the code type-checks, replace each `TBD` individually with two functions, in order: the claim write, then the SDK call it precedes. Only this column changes after coding starts. ⚠⚠ A `TBD` left behind, a function that is not in the code, or one note covering several rows is **not addressed**; if the code does not do what the row says, fix the code, then the cell. ### Step 1c — Required reading (do this before you write any code) @@ -50,30 +65,31 @@ These are API-agnostic usage skills; loading them is not a substitute for the lo Before implementing, check the plan's **Assumptions & Blockers** section: -- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `trader-api-plan.md` in place. +- Blocker or major assumption → surface it to the user in plain language, get their answer, and revise `alpaca-plan.md` in place. - Minor assumptions only → proceed. Full re-planning only on genuine scope change; for a single missing fact mid-implementation, do the lookup, never guess. ### Step 2 — Implement from the contract sheet -1. Read `trader-api-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. +1. Read `alpaca-plan.md` once. Treat its contracts as authoritative — do not re-derive or "double-check" them from memory. When a lookup revises a row, update the file so the sheet and the code never disagree. 2. **Decide sync vs async once, from the host application, before the first call** — and take the decision from the repo survey, not from preference. This SDK ships two complete client classes (`Client` / `AsyncClient`) and there is no bridge between them: a sync client in an `async def` blocks the event loop, and an async client in sync code is a coroutine nobody awaits. Getting this wrong is not a local fix later; it is every call site. The choice also changes the transport-override keyword (`custom_http_client` vs `custom_async_http_client`) and the teardown obligation (`close()` vs `await aclose()`). 3. **Pick the response mode per call, deliberately.** Each operation exists twice: the plain call raises `ApiError` on failure, and its `with_raw_response` peer returns `ApiResult` instead. For the operations that return `None`, the raw peer is the only way to observe the status code — so a call whose outcome you must inspect needs the mode chosen at write time, not retrofitted. 4. Implement sequentially, following the repo's own conventions and layering. You loaded the companion skills the sheet named in Step 1c — implement each step in line with the one that governs it. Take every contract *fact* (signatures, wire aliases, error unions, enum members) from the contract sheet or a fresh lookup — never re-derive one from a companion. 5. After every change: run the project's **type checker** (`mypy`/`pyright`) as well as its tests. The package ships `py.typed` and is generated under `mypy --strict`, so a type error against it is a real contract violation, not noise — it is the closest thing Python gives you to the compile step. **Treat a clean type check as the gate you do not skip.** If the project has no type checker configured, install `mypy` into the environment and run `mypy --strict` on the files you touched — the SDK is generated under that setting, so it checks cleanly against it. If you genuinely cannot install one, say so in your final report rather than silently substituting a smoke run. Fix non-SDK errors yourself. -6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `trader_api.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. -7. Run the project's tests; verify the integration end to end the way the task demands. The SDK performs **no retries at all**, so anything the task needs there is yours to build or deliberately omit — say which you did. +6. **Any error involving an SDK type or member** — an `AttributeError`/`TypeError` on a `alpaca.*` object, a `pydantic.ValidationError` you cannot place, a mypy error naming an SDK type, an unexpected `ApiError` — → go back to `python-getting-started`'s module table and read the one module that owns the fact. Do not attempt more than one self-fix of an SDK-name error before doing that lookup: rewriting from the same knowledge that produced the error is guessing. Remember that a **decode failure raises `ValidationError`/`ValueError`, not `ApiError`, and bypasses both response modes**, and that `httpx2` transport exceptions arrive unwrapped — so an exception that does not look like an API error may still be one of the SDK's failure kinds. +7. Once the code is complete, open each function the **DUPLICATE CLAIMS** rows name and confirm it runs when that write runs. Where the code falls short of a row, fix the code, not the row. +8. Run the project's tests; verify the integration end to end the way the task demands. Retrying is **on by default**, so decide whether the task keeps it, tunes it through `retry_options`, or turns it off with `max_retries` set to `0`, rather than wrapping the call in a retry of your own — and say which you did. ### Step 3 — Answering pure questions -A standalone Trader API question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. +A standalone Alpaca question with no code change: look it up in `python-getting-started` (and the module its table names), then give the grounded answer. Never answer from memory, even for "easy" questions. ## Anti-patterns — never do these - **Always load `python-getting-started` first.** It is your lookup layer, not optional background. (The `python-*` companions are the complement: load the ones the sheet names, per Step 1c.) Don't re-derive a contract *fact* from a companion — exact signatures, wire aliases, error unions and enum members come from the sheet or a fresh lookup. -- **Never write a Trader API/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** -- **Don't web-search Trader API topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Trader API docs describe the REST API, not this SDK's generated surface, and the two disagree on names. +- **Never write a Alpaca/SDK fact from memory** — every signature, field name, enum member, and error union in your code must come from the contract sheet or a lookup. And **never write a call from memory "to fix later".** +- **Don't web-search Alpaca topics to find an implementation detail** — the installed package's own source is the ground truth, and the module table tells you where to look. Public Alpaca docs describe the REST API, not this SDK's generated surface, and the two disagree on names. - **Never introspect the SDK at runtime to discover its shape.** `dir()`, `model_fields`, `inspect.signature`, or a REPL poke is the Python-flavoured version of decompiling the package: it answers what exists, never what is *supported*, and it silently invites private attributes into your code. Read the source module instead. - **Don't grep the package to *locate* a type** — the SDK map is the locator (and says so: *"Never grep for a type"*); grep only *inside* the module its **Type sources** table names, for the symbol. A sweep for a *shape* is a different thing, and is fine: "every member typed `Optional[Any]`", "every discriminator field" are cross-cutting questions nothing indexes, and one targeted `grep -rn` over `models/` is the right tool for them — record what it found on the sheet. - **Don't vendor, `sys.path`-hack, or editable-install the SDK from a throwaway clone** to make a lookup possible. Install the distribution properly into the project's environment (see `python-getting-started`); an editable install pointed at a clone breaks every import the moment the clone is deleted. diff --git a/plugins/alpaca/skills/python/python-models/SKILL.md b/plugins/alpaca/skills/python/python-models/SKILL.md index cfe9f9293..cbf33c8f7 100644 --- a/plugins/alpaca/skills/python/python-models/SKILL.md +++ b/plugins/alpaca/skills/python/python-models/SKILL.md @@ -85,8 +85,9 @@ Prefer either precise form over truthiness when "set but empty" and "not set" ar ## Dict companions Every model has a `{Model}Dict` `TypedDict` companion mirroring it field for field, with optional -members marked `NotRequired`. Anywhere a model is accepted, the companion is too — including nested, -because the companion types each nested member as `{Model} | {Model}Dict`: +members marked `NotRequired`. Anywhere a model is accepted, the companion is too — the choice between +the two spellings is offered at the signature. Inside a companion, a nested model member is typed as +that model's companion alone (`{Model}Dict`), so a dict literal nests all the way down: ```python {RequestType}({required_field}="...", {required_list}=[{"{member}": {"{nested}": "..."}}]) @@ -155,8 +156,11 @@ variant directly. {Variant1}Or{Variant2}: TypeAlias = {Variant1} | {Variant2} {Variant1}Or{Variant2}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict -# oneOf with a discriminator — the same union, tagged -{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")] +# oneOf with a discriminator — the same union, tagged by the wire key +{Union}: TypeAlias = Annotated[ + Annotated[{Variant1}, Tag("{value1}")] | Annotated[{Variant2}, Tag("{value2}")], + WireDiscriminator("{tag}"), +] {Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict ``` @@ -171,13 +175,19 @@ elif isinstance(response.{union_field}, {Variant2}): ... ``` -Three things the alias does not show: - -- **A discriminated variant carries its tag as a defaulted `Literal`** (`{tag}: Literal["{value}"] = - "{value}"`). Building the model gives you the tag for free — you never set it. -- **The dict spelling must carry the tag anyway.** The companion marks it `NotRequired`, but pydantic - routes a tagged union on that key, so omitting it raises `ValidationError` (`union_tag_not_found`) - at runtime while type-checking cleanly. Pass the model, or include `"{tag}": "{value}"` in the dict. +Four things the alias does not show: + +- **The tag belongs to the union, not to the variant.** Build the variant you mean and pass it; the + union writes the tag on the way out. One model can be an arm of several unions that tag it + differently, so do not expect to find a tag field on it. +- **Some variants do carry the tag as a field, because the API declares it on them.** Where the + field is there it is yours to set, and it is sent exactly as you wrote it — nothing overwrites it. + Where it is not, `to_dict()` on that model carries no tag, so hand the model to the SDK rather + than dumping it and posting the dict yourself. +- **The dict spelling must carry the tag.** Read the union's companion alias: where it names a + wrapper (`{Union}{Variant1}Dict`), use that one — it is the variant's dict shape plus the tag key. + Omitting the key raises `ValidationError` (`union_tag_not_found`) at runtime while type-checking + cleanly. - **A union whose arms collapse to one is inlined at the use site** — the field is simply typed `{Variant} | None` and no alias module exists. Do not go looking for an alias the sheet does not list. @@ -185,7 +195,8 @@ Three things the alias does not show: ## Collections List properties are `list[T]`; maps are `dict[str, V]` — **the key type is always `str`**, whatever -the spec said. A nested model element accepts its companion too (`list[{Model} | {Model}Dict]`). +the spec said. Inside a companion, a model element is typed as that model's companion +(`list[{Model}Dict]`). ```python body = {RequestType}( @@ -242,10 +253,11 @@ worth knowing: - A never-touched `OptionalNullable` field is **omitted** by `to_dict`, even though a plain `model_dump` renders it as `null`. That is the tri-state being honoured; it is the one documented place the wrapper differs from the underlying dump. -- **`exclude_unset=True` is a trap on a locally built model.** It drops defaulted discriminator - fields, after which the result no longer validates back against a discriminated union. Use - `exclude_none=True` if your goal is just to suppress nulls. (On a *decoded response* it is the - right tool — see the read-path note under the `Optional[Any]` trap below.) +- **`exclude_unset=True` is a trap on a locally built model.** It drops any field still sitting on + its default — including a tag field that has one — after which the result + no longer validates back against the union. Use `exclude_none=True` if your goal is just to + suppress nulls. (On a *decoded response* it is the right tool — see the read-path note under the + `Optional[Any]` trap below.) ## The `Optional[Any]` trap — a real serialization failure @@ -336,11 +348,11 @@ contract sheet rather than assuming: | integer widths (`integer`, `long`) | `int` | | fractional numbers (`number`, `decimal`) | **`float`** | | `boolean` | `bool` | -| `binary`, `file` | `bytes` | +| `binary`, `file` | `bytes` — **in a model member or a text position only**; see the third consequence below | | `date` | the SDK's `Date` converter | | `date-time` | `RFC3339DateTime`, `RFC1123DateTime` or `UnixSecondsDateTime` | -Two consequences: +Three consequences: - **Date/time fields use the SDK's converter types**, so the wire format is handled for you. Assign a `datetime`/`date` and read one back; do not format strings by hand. Every one of those names is an @@ -351,13 +363,36 @@ Two consequences: - **There is no `Decimal` arm.** A fractional number becomes `float`, so a spec that models money as a number gives you binary floating point. Most payment APIs instead model money as a **`str`** scaled to the currency (`"10.00"`) — where yours does, build it with `Decimal` and format explicitly, never - with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`: + with `%f`/`round()` and never through a locale-dependent conversion that can produce `"10,00"`. + **The decimal format is specific to the currency**, never the literal `2`: ```python from decimal import Decimal - value = f"{Decimal('10.00'):.2f}" + + # ISO 4217 minor units (List One, published 2026-09-17) for every currency that does not have two. + # A provider can differ from ISO; where it documents its own rule, that rule wins. + EXPONENT = { + "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0, "KRW": 0, "PYG": 0, + "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0, "XAF": 0, "XOF": 0, "XPF": 0, + "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3, + "CLF": 4, "UYW": 4, + } + + def amount(value: Decimal, currency: str) -> str: + places = EXPONENT.get(currency, 2) + return str(value.quantize(Decimal(1).scaleb(-places))) + + amount(Decimal("10"), "USD") # "10.00" + amount(Decimal("1000"), "JPY") # "1000" -- not "1000.00" + amount(Decimal("1.2344"), "KWD") # "1.234" -- not "1.23" ``` +- **`binary`/`file` is never `bytes`.** A file position takes `FileInput` (`AsyncFileInput` on the + async client) and an operation that returns one hands back `FileResponse` (`AsyncFileResponse`). + Both pairs live in `{root_package}.core` rather than `{root_package}.models`, so nothing on this + page applies to them — see **python-file-handling**. Nothing base64-encodes or decodes for you + either: where an endpoint asks for base64, hand it content that already is. + Currency codes are typically plain `str` with no enum — pass `"USD"`. ## ValidationError is your friend, not an error to suppress diff --git a/plugins/alpaca/skills/python/python-models/reference.md b/plugins/alpaca/skills/python/python-models/reference.md index 257af32d5..8524cc0a9 100644 --- a/plugins/alpaca/skills/python/python-models/reference.md +++ b/plugins/alpaca/skills/python/python-models/reference.md @@ -87,8 +87,11 @@ from {root_package}.models import {Union} # also {root_package}.models. `{Union}Dict: TypeAlias = {Variant1}Dict | {Variant2}Dict`. Named after its arms when the spec gave it no name (`{Variant1}Or{Variant2}`). - **Discriminated (`oneOf` with a discriminator, or an `allOf` base with subtypes)** — - `{Union}: TypeAlias = Annotated[{Variant1} | {Variant2}, Field(discriminator="{tag}")]`. Each variant - declares the tag as a defaulted `Literal`, so constructing the variant sets it for you. + `{Union}: TypeAlias = Annotated[Annotated[{Variant1}, Tag("{value1}")] | …, WireDiscriminator("{tag}")]`. + The tag is the union's: build the variant and the union writes it. Some variants also declare the + tag as a field of their own, in which case it is yours to set. Where the companion alias names a + wrapper (`{Union}{Variant1}Dict`), that is the dict spelling to use — the variant's own dict shape + plus the tag key. - **One surviving arm** — no alias module is emitted at all; the field is typed with that arm directly (`{Variant} | None` where a dropped arm was nullable). An alias the sheet does not list does not exist. diff --git a/plugins/alpaca/skills/python/python-testing/SKILL.md b/plugins/alpaca/skills/python/python-testing/SKILL.md index e4e971b34..883b947a1 100644 --- a/plugins/alpaca/skills/python/python-testing/SKILL.md +++ b/plugins/alpaca/skills/python/python-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: python-testing -description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol, or respx at the httpx layer), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. +description: Testing code that calls an APIMatic-generated Python SDK — which seam to fake (the transport protocol), asserting on the request the SDK actually built, covering the error and decode-failure paths, and keeping tests independent of SDK internals. Load before writing tests for the integration layer. --- # Testing code that uses an APIMatic Python SDK @@ -23,16 +23,42 @@ Substitute your own `{Api}Client` and operation names as well. ## A reusable stub transport The transport is a `Protocol`, so a fake needs no base class, no registration and no `Mock` — just -the two methods: +its two methods, `send` and `close`. `send` answers with the response **head**; the body stays on +the stub until something reads it, which is what lets one stub serve a JSON call and a download +alike: ```python import json +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field from {root_package}.core import HttpRequest, HttpResponse +@dataclass +class StubResponse: + """A canned head, with the body held as chunks until the SDK reads it.""" + + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]: + yield from self.chunks + self.close() # a drained iteration releases the connection; a partial one does not + + def read(self) -> bytes: + self.close() # reading both buffers the body and releases the connection + return b"".join(self.chunks) + + def close(self) -> None: + self.closed = True + + class StubTransport: """Satisfies the SDK's sync transport protocol: send() + close().""" - def __init__(self, *responses: HttpResponse) -> None: + def __init__(self, *responses: StubResponse) -> None: self._responses = list(responses) # Every request, in order — the token fetch appends too, so this is what you count. self.requests: list[HttpRequest] = [] @@ -44,34 +70,40 @@ class StubTransport: def close(self) -> None: ... @property - def last_request(self) -> HttpRequest | None: - return self.requests[-1] if self.requests else None + def last_request(self) -> HttpRequest: + return self.requests[-1] # IndexError when nothing was sent -def json_response(status: int, body: object) -> HttpResponse: - return HttpResponse( +def json_response(status: int, body: object) -> StubResponse: + return StubResponse( status_code=status, headers={"content-type": "application/json"}, # lowercase keys, per the transport contract - content=json.dumps(body).encode(), + chunks=[json.dumps(body).encode()], ) ``` -`HttpResponse` defaults `content` to `b""` and `request` to `None`, so an empty `204` is just -`HttpResponse(status_code=204, headers={})` — which is what an operation declaring no response body -expects (`python-calling-endpoints`). +A download is queued exactly like anything else: one seam means one kind of stubbed response, and +nothing about the stub says which operation it answers. Assert on `StubResponse.closed` to prove the +caller consumed the body and released the connection. + +`chunks` defaults to empty, so an empty `204` is just `StubResponse(status_code=204)` — which is what +an operation declaring no response body expects (`python-calling-endpoints`). Wire it in and you have a real client with no network: ```python -def client_returning(*responses: HttpResponse) -> tuple[{Api}Client, StubTransport]: +def client_returning(*responses: StubResponse) -> tuple[{Api}Client, StubTransport]: transport = StubTransport(*responses) - client = {Api}Client(custom_http_client=transport, {scheme}={"...": "..."}) + # retry_options=0: retries are on by default, so without it a stubbed 429 or 503 on a GET would be + # sent four times, with real waits between them. + client = {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...) return client, transport ``` The keyword is `custom_http_client=` on the sync client and `custom_async_http_client=` on the async one; both constructors are keyword-only, and the credential keyword is named after the scheme the -contract sheet lists. +contract sheet lists. Its value is that scheme's type: a plain `str` for a bearer token or an API key, +the credentials model or dict for basic auth or an OAuth grant (`python-authentication`). **Do not patch the client's private attributes** (`_raw_client`, `_auth`, …) and do not `Mock(spec={Api}Client)`. Both couple your tests to internals the generator is free to change, and a @@ -88,7 +120,7 @@ endpoint, not your operation. Two ways to handle it. Queue a token response ahead of the operation's, and index from the end: ```python -def token_response() -> HttpResponse: +def token_response() -> StubResponse: return json_response(200, {"access_token": "t", "token_type": "Bearer", "expires_in": 3600}) client, transport = client_returning(token_response(), json_response(201, {...})) @@ -101,15 +133,23 @@ Or bypass acquisition entirely with a stub token source — the generator's supp (`python-authentication`) — so the stub transport only ever sees your operation: ```python -from {root_package}.core import OAuthToken +from {root_package}.core import OAuthTokenRefreshable + +# {Credentials} is the type inside your client's `{scheme}_token_source` annotation — for example +# `ClientCredentials[{Scope}]` — imported the way the client module imports it. +class StubTokenSource: # TokenSource and RefreshableTokenSource + def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... # authorization code needs it -class StubTokenSource: - def fetch(self, credentials) -> OAuthToken: # async client: `async def fetch` - return OAuthToken(access_token="t", token_type="Bearer") +class AsyncStubTokenSource: # AsyncTokenSource and AsyncRefreshableTokenSource + async def fetch(self, credentials: {Credentials}) -> OAuthTokenRefreshable: + return OAuthTokenRefreshable(access_token="t", token_type="Bearer") + async def refresh(self, credentials: {Credentials}, refresh_token: str) -> None: ... client = {Api}Client( custom_http_client=transport, - {scheme}={"...": "..."}, # still required — the scheme exists only if it is set + {scheme}={"...": "..."}, # still required: a valid credential for the grant (`python-authentication`) {scheme}_token_source=StubTokenSource(), ) ``` @@ -117,17 +157,20 @@ client = {Api}Client( The token is **cached on the scheme**, so only the first call fetches one: a test making two calls against one client sees three requests, not four. Forgetting the token request is the most common way a first test fails confusingly — the operation's decoder is handed the token body, and you get a -`ValidationError` about your payload rather than anything mentioning auth. +`ValidationError` about your payload rather than anything mentioning auth. The tests below queue only +the operation's response; for OAuth where the SDK fetches the token itself, put `token_response()` first +(except in the bad-credentials test). ## Test a success path ```python def test_returns_deserialized_body(): - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "..."})) + client, transport = client_returning(json_response(200, {"{wire_field}": "..."})) value = client.{Group}.{Operation}(...) assert value.{member} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` ## Test an error path @@ -145,7 +188,7 @@ from {root_package}.core import ApiError from {root_package}.models import {TypedError} def test_raises_on_api_error(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) @@ -154,6 +197,7 @@ def test_raises_on_api_error(): assert e.status_code == 422 # always present, whichever arm you got assert isinstance(e.error, {TypedError}) # narrow before reading fields assert e.error.{message_field} == "..." + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **`RawError` arm** — a status the operation does not document, which every operation can produce: @@ -162,13 +206,14 @@ def test_raises_on_api_error(): from {root_package}.core import RawError def test_unmapped_status_is_raw(): - client, _ = client_returning(token_response(), json_response(418, {"whatever": 1})) + client, transport = client_returning(json_response(418, {"whatever": 1})) with pytest.raises(ApiError) as exc_info: client.{Group}.{Operation}(...) assert isinstance(exc_info.value.error, RawError) assert exc_info.value.error.status_code == 418 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token # Body on demand: .text() always, .json() only if you know it is JSON (it raises ValueError). ``` @@ -182,23 +227,25 @@ contract. If your code uses the non-raising peer (`python-calling-endpoints`), there is nothing to catch — stub the response and assert on the returned `ApiResult` directly. The split is mechanical: **2xx is -`Success`, everything else is `Failure`**, and both carry `.response`. +`Success`, everything else is `Failure`**, and both carry `.status_code` and `.headers`. ```python from {root_package}.core import Failure, Success def test_raw_response_reports_failure_without_raising(): - client, _ = client_returning(token_response(), json_response(422, {"...": "..."})) + client, transport = client_returning(json_response(422, {"...": "..."})) result = client.{Group}.with_raw_response.{Operation}(...) assert isinstance(result, Failure) assert isinstance(result.error, {TypedError}) # the same union as the raising path - assert result.response.status_code == 422 + assert result.status_code == 422 + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` -`.response` is the only way to read **response headers**, so this is also the mode to test in when -your code depends on a header on the *success* path (`Success(payload=..., response=...)`). +This is also the mode to test in when your code depends on a header on the *success* path +(`Success(payload=..., status_code=..., headers=...)`), because the parsed form exposes neither — +except for a file response, which carries its own `headers` on the parsed path. ## Assert the outgoing request @@ -206,10 +253,13 @@ your code depends on a header on the *success* path (`Success(payload=..., respo actually change: ```python +from {root_package}.core import JsonBody + req = transport.last_request assert req.method == "POST" assert req.url.endswith("/{expected/path}") assert "{query_param}=..." in req.url # query is part of the URL string +assert isinstance(req.body, JsonBody) and isinstance(req.body.value, dict) # narrow before indexing assert req.body.value["{wire_field}"] == "..." # JsonBody carries the dumped payload assert req.headers["authorization"] == "Bearer t" # lowercase key — see below assert req.headers["{header}"] == "..." # a real default reached the wire @@ -223,8 +273,11 @@ request assertion fails confusingly. (The defensive `"authorization" in {k.lower req.headers}` also works, but there is no case to defend against — assert the lowercase key.) **The body is already serialized by the time a transport sees it.** `req.body` is one of `JsonBody` -(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`) or `MultipartBody` -(`.fields` / `.files`) — no transport ever serializes anything — so assert on the dumped value +(`.value`, the dumped JSON-safe object), `FormBody` (`.fields`), `MultipartBody` (`.parts`, an +ordered sequence of `MultipartText` and `MultipartFile` — **not** a `.fields`/`.files` pair, and a +file part's `content` is the unread descriptor you passed, never bytes), `BinaryBody` +(`.content`, `.media_type`) or `TextBody` (`.text`, `.media_type`, `.charset`) — no transport ever +serializes anything — so assert on the dumped value directly rather than re-parsing bytes. Note it uses **wire aliases**, because that is what serialization produces: if a model's Python name differs from its JSON name, the request body has the JSON name. Do not "fix" that in the test. @@ -264,9 +317,11 @@ path is the norm (`python-error-handling`). Each is cheap to simulate. ```python def test_unreadable_success_body_is_not_reported_as_failure(): # A real decode failure needs a TYPE mismatch, not an absent member. - client, _ = client_returning(token_response(), json_response(200, {"{wire_field}": "not-a-list"})) - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {"{wire_field}": "not-a-list"})) + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A truncated 2xx that decodes cleanly.** Members are required only where the description marks them @@ -275,21 +330,62 @@ your own guard catches it, so test the guard: ```python def test_truncated_success_body_is_not_reported_as_success(): - client, _ = client_returning(token_response(), json_response(200, {})) # no {wire_field} - with pytest.raises(MyProviderUnreadable): + client, transport = client_returning(json_response(200, {})) # no {wire_field} + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.outcome_unknown # unreadable: may have happened, never "failed" + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` **A transport failure**, which arrives as the HTTP library's own exception, unwrapped — have the stub -raise instead of answering: +answer any token response you queue, then raise on the operation's request: ```python -def test_transport_failure_is_unknown_outcome(): - class Boom: - def send(self, request): raise httpx.ConnectError("refused") - def close(self) -> None: ... +class RaisingTransport(StubTransport): + """Answers the queued responses, then raises `error` on the next send.""" + + def __init__(self, error: Exception, *responses: StubResponse) -> None: + super().__init__(*responses) + self._error = error + + def send(self, request: HttpRequest) -> HttpResponse: + if self._responses: # a queued token response, if any + return super().send(request) + self.requests.append(request) + raise self._error + + +def client_raising(error: Exception, *responses: StubResponse) -> tuple[{Api}Client, RaisingTransport]: + # Pass token_response() only for OAuth where the SDK fetches the token itself (no + # {scheme}_token_source); never for bearer, API key or basic auth. + transport = RaisingTransport(error, *responses) + return {Api}Client(custom_http_client=transport, retry_options=0, {scheme}=...), transport + + +def test_refused_connection_is_a_known_outcome(): + client, transport = client_raising(httpx2.ConnectError("refused")) # never sent + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (502, False) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token + + +def test_read_timeout_is_an_unknown_outcome(): + client, transport = client_raising(httpx2.ReadTimeout("no reply")) # may have landed + with pytest.raises(ProviderError) as e: + service.{operation}() + assert (e.value.status_code, e.value.outcome_unknown) == (504, True) + assert "/{expected/path}" in transport.last_request.url # the operation, not the token ``` +**Two transport failures, both inputs — never one.** A refused connection was never sent, so its +outcome is known: nothing happened. A read timeout may have landed, so its outcome is not. Test both +and expect **different** values — as the two tests above, or as the single two-input test +`python-error-handling` shows. A test that builds only one of them, or two that one assertion +satisfies, cannot tell the cases apart — and then neither can your code. When the SDK fetches a token, +let the token request through first: a failure *there* means your operation was never sent at all, +whatever the exception is called. + And the one people forget: **bad credentials**. A failed token fetch raises `ApiError` too, but its payload is `OAuthProviderError | RawError` — *not* the operation's union — and it surfaces out of the operation call. Return an RFC 6749 error body from the **token** request and assert your @@ -298,60 +394,72 @@ configuration error, not a rejection error: ```python def test_bad_credentials_is_a_config_error(): client, _ = client_returning(json_response(401, {"error": "invalid_client"})) # no token_response() - with pytest.raises(MyProviderConfigError): + with pytest.raises(ProviderError) as e: service.{operation}() + assert e.value.status_code == 502 # our credentials: never the caller's 401 ``` ## Async tests -Same seam, async shape — `async def send`, and `aclose` rather than `close`: +Same seam, async shape — `async def send`, and `aclose` rather than `close`. The async client reads +the response with `aiter_bytes`, `aread` and `aclose`, so queue an `AsyncStubResponse`, never a +`StubResponse`: ```python +from collections.abc import AsyncIterator +from {root_package}.core import AsyncHttpResponse, HttpRequest + +@dataclass +class AsyncStubResponse: + status_code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + chunks: list[bytes] = field(default_factory=list) + url: str = "https://api.test/resource" + closed: bool = False + + async def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]: + for chunk in self.chunks: + yield chunk + self.closed = True + + async def aread(self) -> bytes: + self.closed = True + return b"".join(self.chunks) + + async def aclose(self) -> None: + self.closed = True + + +def async_json_response(status: int, body: object) -> AsyncStubResponse: + return AsyncStubResponse( + status_code=status, + headers={"content-type": "application/json"}, + chunks=[json.dumps(body).encode()], + ) + + class StubAsyncTransport: - def __init__(self, *responses): self._responses = list(responses); self.requests = [] - async def send(self, request): + def __init__(self, *responses: AsyncStubResponse) -> None: + self._responses = list(responses) + self.requests: list[HttpRequest] = [] + async def send(self, request: HttpRequest) -> AsyncHttpResponse: self.requests.append(request) return self._responses.pop(0) async def aclose(self) -> None: ... + @property + def last_request(self) -> HttpRequest: + return self.requests[-1] @pytest.mark.asyncio async def test_operation(): - client = Async{Api}Client(custom_async_http_client=StubAsyncTransport(...), {scheme}=...) + transport = StubAsyncTransport(async_json_response(200, {...})) + client = Async{Api}Client(custom_async_http_client=transport, retry_options=0, {scheme}=...) ``` Use `pytest-asyncio` or `anyio`, whichever the project already uses. The type checker rejects a sync stub passed to the async client and vice versa, which is a genuine safety net — do not silence it with a `# type: ignore`. -## The alternative: `respx` - -Because the SDK's default transport is httpx, `respx` mocks at the HTTP layer and needs no injection — -your production code constructs its client unmodified: - -```python -import respx, httpx - -@respx.mock -def test_operation(): - respx.post("{base_url}{token_path}").mock( - return_value=httpx.Response(200, json={"access_token": "t", "token_type": "Bearer"}) - ) - route = respx.post("{base_url}{operation_path}").mock( - return_value=httpx.Response(201, json={"{wire_field}": "..."}) - ) - - service.{operation}() # production code, unmodified - - assert route.called - assert json.loads(route.calls.last.request.content)["{wire_field}"] == "..." -``` - -Pick one and be consistent. `respx` asserts on real URLs and is closer to the wire; the stub transport -is dependency-free, faster, and keeps working if the SDK ever changes HTTP library. Use `respx` when -you want URL-level matching, the stub when you are unit-testing your own logic. Note `respx` only -works while the default transport is in play — it cannot see a call made through a -`custom_http_client` you supplied. - ## Keeping tests independent of SDK internals - **Never import from a private module.** Everything you need is re-exported from @@ -365,9 +473,11 @@ works while the default transport is in play — it cannot see a call made throu derives its payload from the model's own definition cannot detect a wrong payload. - **Build fixtures with models, then serialize** — `{request_fixture}().to_dict()` — rather than hand-writing wire JSON, so a member rename fails the fixture instead of passing a stale test. -- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a provider 4xx - becomes your 4xx and a transport failure becomes your 5xx. That the SDK raises `ApiError` on a 422 - is the SDK's own tested behaviour, not yours. +- **Test your own boundary's output, not the SDK's.** The valuable assertions are that a 4xx the + caller caused becomes your 4xx, a 4xx that is **yours** — `401`, `403`, `429` — becomes your `502` + or `503`, and a transport failure becomes `502` if it was never sent and `504` if it may have landed + (`python-error-handling`). That the SDK raises `ApiError` on a 422 is the SDK's own tested + behaviour, not yours. ## Integration tests against a live environment @@ -378,27 +488,38 @@ assert on ids or timestamps the provider generates: @pytest.mark.integration @pytest.mark.skipif(not os.getenv("{API}_CLIENT_ID"), reason="sandbox credentials not configured") def test_real_{operation}(): - with {Api}Client({scheme}=..., base_url={sandbox_url}, timeout=15.0) as client: + with {Api}Client({scheme}=..., {server_keyword}={sandbox}, timeout=15.0) as client: value = client.{Group}.{Operation}(...) assert value.{member} ``` Point them at the sandbox/test environment **explicitly** rather than relying on the default, so the -test states which environment it needs. Expect flakiness from the provider, not from your code, and +test states which environment it needs. `{server_keyword}` is `environment=` wherever the client has +one, else `base_url=` or `server_config=`, with the value shaped as the table in +`python-configuration-resilience` shows. Expect flakiness from the provider, not from your code, and never gate CI on a third party's uptime unless you mean to. ## Notes -- **The SDK performs no retries**, so a stubbed `429`/`503` is seen exactly once and your stub's queue - needs exactly one response for it. If your code adds its own retry layer - (`python-configuration-resilience`), that is *your* code under test: queue the responses each - attempt should get and count `transport.requests`. +- **Retries are on by default**, so a stubbed `429`/`503` on a `GET`, `HEAD`, `PUT` or `OPTIONS` call is + sent up to four times, with real backoff waits between them. Build test clients with + `retry_options=0` so one stubbed response is seen exactly once. In a test *about* retrying, set a + policy with `initial_delay` and `max_jitter` small, queue one response per attempt and count + `transport.requests`. For the loop to retry a + *transport* failure the fake must raise `TransportError` from `{root_package}.core` — a + bare `httpx2.ConnectError` bypasses the loop and reaches the test on the first attempt + (`python-configuration-resilience`). - **A `401` invalidates the cached token but does not retry the request.** A test that stubs a `401` therefore sees one failed operation — and the *next* call in the same test re-fetches a token, so queue another `token_response()` for it. - Mocking libraries (`unittest.mock`, `pytest-mock`) work too — the protocol is structural, so a - `Mock()` with `send`/`close` configured satisfies it. The hand-written stub above gives you typed - captured requests and ordered responses for free. + `Mock()` with `send` and `close` configured satisfies it. The hand-written stub above gives you + typed captured requests and ordered responses for free. +- **Stubbing a download** needs nothing extra: the SDK wraps the same `StubResponse` in the + `FileResponse` your assertions see, so the stub decides what the caller reads and when the + connection is released. Give it `chunks` if the test drives `iter_bytes`. What the caller may then + do with it — and the `ResourceWarning` an abandoned one emits, which a category default filters + unless you enable it here — is `python-file-handling`. - **Use the client as a context manager or close it** in tests as in production; a stub's `close()` is a no-op, but the habit keeps the test and the real wiring the same shape (`python-client-initialization`). diff --git a/plugins/alpaca/skills/typescript/typescript-authentication/SKILL.md b/plugins/alpaca/skills/typescript/typescript-authentication/SKILL.md index a72211835..ae49a764b 100644 --- a/plugins/alpaca/skills/typescript/typescript-authentication/SKILL.md +++ b/plugins/alpaca/skills/typescript/typescript-authentication/SKILL.md @@ -55,7 +55,7 @@ const client = new {Api}Client({ Sends `Authorization: Basic base64(username:password)`. -A username containing `:` throws `SdkError` **from the constructor** (RFC 7617 §2), so a bad value +A username containing `:` throws `ConfigurationError` **from the constructor** (RFC 7617 §2), so a bad value fails fast rather than on the first call. The password is not validated — an empty one is sent as an empty one. @@ -88,7 +88,7 @@ than replacing it — only a cookie of the same name is overwritten. ### A `TokenProvider` may be a function ```ts -type TokenProvider = string | (() => string | Promise); +type TokenProvider = string | ((signal: AbortSignal) => string | Promise); ``` Pass a **function** when the secret rotates, comes from a vault, or is not known at construction time. @@ -163,19 +163,22 @@ never see or manage it. - **PKCE is on by default.** Omitting `pkce` means `PkceMethod.S256`; `pkce: null` disables it. The verifier and challenge are generated with `crypto.getRandomValues` / `crypto.subtle`, so the runtime needs Web Crypto (Node 20+ and browsers have it). -- The `signal` handed to your callback is the request's own signal — honour it, or a cancelled or - timed-out request leaves you waiting on a redirect forever. - -⚠ **The prompt runs on the triggering request's timeout budget** — the clock starts before auth is -resolved, so a human has `timeout` milliseconds (default `60_000`) to complete the whole login, and -whatever they take is subtracted from the operation's own budget. A browser round-trip does not fit -inside a default timeout. Acquire the token out of band (*Supplying your own token source*) rather -than raising `timeout` to human scale, which would also un-bound every ordinary call. +- The `signal` handed to your callback is the call's own signal, or one that never aborts when the + call was given none — honour it, or a cancelled call leaves you waiting on a redirect forever. +- **Rejecting the callback is how you decline.** A user who closes the window, or a callback that + throws for any other reason, surfaces as `AuthError` with your error on `.cause`, and no token + request is sent. It is deliberately *not* a `ConnectionError`: a connection failure invites a + retry, a declined consent needs the user asked again. Two rejections pass through untouched + instead of being wrapped — one of the SDK's own error classes, and, when the call has been + cancelled underneath you, the signal's own `reason`. + +⚠ **No timeout covers the prompt, and it is not retried.** Only the call's `signal` ends it early, +and only if the prompt honours it. ⚠ **The prompt must not make an SDK call that uses this same scheme.** Concurrent acquisitions share one in-flight promise, so a call made from inside the prompt waits on the acquisition that is waiting -on the prompt — a deadlock that breaks only when the request timeout fires. The same applies to a -custom token source's `getToken`. +on the prompt — a deadlock that breaks only when a signal aborts. The same applies to a custom token source's +`getToken`. ⚠ **`state` is passed through verbatim and the SDK does not validate it on the way back.** It is sent on the authorization request and never generated or checked here — the SDK never sees the redirect, so @@ -218,8 +221,13 @@ const client = new {Api}Client({ operation. One in-flight acquisition is **shared**: concurrent requests that arrive during a fetch wait on that fetch rather than each starting their own. - The fetch is a form-encoded `POST` to the token endpoint, sent with **no auth scheme of its own**, - and it runs on the triggering request's timeout and abort signal — so token-fetch time comes out of - that operation's budget. + and it runs on its own `retry.timeout` and the call's abort signal — so token-fetch time does not + come out of the operation's attempt. +- **A failure while obtaining the token is not retried by the call.** It ends the call. +- **The token request is itself a `POST` under the same policy.** With the default + `httpMethodsToRetry`, an error status from the token endpoint — a `503` included — is not retried: + it is an `AuthError` and ends the call. Name `POST` in the gate and the token request retries a + retryable status like any other `POST`; the `AuthError` ends the call once those retries are spent. - **The cache lives in the client object.** Build the client once (see `typescript-client-initialization`) or every call re-runs the grant. - **Only the authorization-code grant refreshes.** It is the one grant wired to @@ -230,8 +238,8 @@ const client = new {Api}Client({ - When the authorization-code grant does refresh, a refresh response that omits `refresh_token` carries the previous one forward. A refresh that fails does not throw — it returns `null` internally and falls back to a full re-grant. -- On `401`, the cached token is invalidated and re-acquired on the **next** call — the failing request - is **not** retried. Handle the 401 as an ordinary `ResponseError` (see `typescript-error-handling`); +- On `401`, the cached token is invalidated and re-acquired on the **next** call — `401` is not in the + default `statusCodesToRetry`, so the failing request is **not** re-sent. Handle the 401 as an ordinary `ApiError` (see `typescript-error-handling`); the recovery is automatic on the following attempt, not on this one. ⚠ **A `401` on the authorization-code grant is an interactive re-authorization, not a silent @@ -311,8 +319,9 @@ one fact — the token fetch happens *during* your operation: 1. **The traceback points at the operation you called**, not at anything named "auth". A token endpoint that answers `400 invalid_client` becomes an `AuthError` thrown from - `client.{resource}.{operation}(...)`, with the underlying `ResponseError` on `.cause`. Reading only - the top frame sends you looking in the wrong place entirely. + `client.{resource}.{operation}(...)`, with the underlying `ApiError` on `.cause` — and its own + `method` and `uri` name that operation, not the token endpoint. Reading only the top frame sends you + looking in the wrong place entirely. 2. **The non-throwing form still rejects.** `.asApiResult()` converts the *operation's* error response into an `{ ok: false }` result instead of throwing — but auth is resolved before any response exists, so an `AuthError` propagates straight out of the `await`. Code written specifically to @@ -329,13 +338,13 @@ try { } catch (err) { if (err instanceof AuthError) { // Credentials/token problem — the operation request was never sent. - throw new ConfigurationError("API credentials rejected", { cause: err }); + throw new Error("API credentials rejected", { cause: err }); } throw err; } ``` -Treat it as a **configuration** failure, distinct from an operation rejection: nothing was attempted, +Treat it as an **auth** failure, distinct from an operation rejection: nothing was attempted, so retrying it or reporting it as a failed operation are both wrong. `typescript-error-handling` has the full catch ladder. @@ -375,8 +384,8 @@ Every section above covers supplying a credential. This one covers its **absence configuration-driven app hits in the real world, and the one most integrations get wrong. **Every credential member is optional and an unset one does not throw.** The operation that wanted it -simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as a -`ResponseError`, not as an auth failure. The SDK will never tell you that you forgot to supply a +simply sends no credential, and you get whatever the API answers — typically a `401`, surfacing as an +`ApiError`, not as an auth failure. The SDK will never tell you that you forgot to supply a credential; only the provider will, one round-trip later and one layer away from the cause. A "mysterious 401" is very often an unfilled option member — check that before suspecting the credentials themselves. @@ -440,17 +449,21 @@ absent, so a blank env var is the same fault as an unset one. set earlier under that name. There is no per-request header override to undo it — a `fetch` wrapper is the only place you can strip or replace an auth header (see `typescript-configuration-resilience`). -- **Nothing hides secrets in a log.** Credentials are plain objects with no redacting `toString`, so - `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK also logs - nothing itself, so anything that leaks is something you wrote. +- **Nothing hides secrets in a log you write.** Credentials are plain objects with no redacting + `toString`, so `console.log(options)` or a serialized `ClientOptions` prints them in full. The SDK's + own logging, off by default, masks the `Authorization` header, a query value off its safe list and + the OAuth form keys (`client_secret`, `password`, `refresh_token`, …) — but a JSON request body it + logs verbatim once `logRequestBody` is on (see **typescript-configuration-resilience**). - On the OAuth 2 token request, header placement sends `base64(clientId:clientSecret)` **without percent-encoding either half** first. A client id or secret containing a character RFC 6749 §2.3.1 would have you encode is sent raw, so a provider that follows the RFC strictly may reject it — use body placement or a custom strategy if your secret is not in the unreserved set. -- **`AuthError` means a credential could not be obtained** — the token request failed, or PKCE was - disabled without a secret. Being *refused* a credential by the API is a `ResponseError` with status - 401/403 instead. The two are disjoint, and one `catch` arm cannot absorb the other. A token endpoint - that answers with an error status surfaces as `AuthError` with that `ResponseError` on `.cause`. +- **`AuthError` means a credential could not be obtained** — the token request failed, every + configured branch of an alternatives requirement failed, PKCE was disabled without a secret, or your + `promptForAuthorizationCode` rejected. Being *refused* a credential by the API is an `ApiError` with + status 401/403 instead, so one `catch` arm cannot absorb the other. A token endpoint that answers with + an error status surfaces as `AuthError` with that `ApiError` on `.cause`; an alternatives requirement + whose every branch failed carries an `AggregateError` there, holding each branch's failure. - Credentials never appear in a thrown error's message. - **A token endpoint may live on a different server group than the operations.** Overriding `serverOptions.default.{environment}.baseUrl` therefore does not move the token request — override diff --git a/plugins/alpaca/skills/typescript/typescript-calling-endpoints/SKILL.md b/plugins/alpaca/skills/typescript/typescript-calling-endpoints/SKILL.md index fb5ceff39..74e5dd04c 100644 --- a/plugins/alpaca/skills/typescript/typescript-calling-endpoints/SKILL.md +++ b/plugins/alpaca/skills/typescript/typescript-calling-endpoints/SKILL.md @@ -47,7 +47,8 @@ Every operation has the same shape, with no positional overload and no sync vari - **There is exactly one request parameter**, and it is **not optional** — even when every field on it is. An all-optional request still needs `{}` passed explicitly. - **`options` is on every generated operation**, always last, always optional. It is - `RequestOptions` = `{ signal?: AbortSignal }` and nothing else — see [Cancellation](#cancellation). + `RequestOptions` = `{ signal?: AbortSignal; retry?: RequestRetryOptions }` and nothing else — see + [Cancellation and per-call retry](#cancellation-and-per-call-retry). - **An operation may declare no request parameter at all.** When every value the request carries is fixed by the spec, the generator emits no parameter and exports no request type, and `options` moves into first position. Check the **Signature** bullet before passing `{}` — it will not type-check @@ -122,27 +123,49 @@ const request: {Resource}.{Operation}Request = { /* ... */ }; ## Building request models -**Only a JSON- or text-bodied operation has a `body` field.** Where the API declares an -`application/x-www-form-urlencoded` request, the generator emits the parts as **individual flat request -fields** alongside the path and query ones and assembles the form itself — there is no nested body -object to construct and no `body` field to pass. The operation's **Request body** bullet says which -you are looking at; the signature settles it. +**A JSON, text or binary operation has a `body` field.** Where the API declares an +`application/x-www-form-urlencoded` or `multipart/form-data` request, the generator emits the parts as +**individual flat request fields** alongside the path and query ones and assembles the form itself — +there is no nested body object to construct and no `body` field to pass. The operation's **Request +body** bullet says which you are looking at; the signature settles it. -The runtime carries exactly four request-body shapes: **empty, JSON, form-urlencoded and text**. A -**multipart, binary or XML** body has no carrier, so the operation is still emitted but **degrades to -no body field at all** — there is nothing to supply the payload through, and the request goes out with -no body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it +The runtime carries six request-body shapes: **empty, JSON, form-urlencoded, text, multipart and +binary**. An **XML** body has no carrier, so the operation is still emitted but **degrades to no body +field at all** — there is nothing to supply the payload through, and the request goes out with no +body. Its **Request body** bullet reads `none` in that case. If you need such an endpoint, call it with `fetch` directly; the SDK cannot send it. +**A file field takes bytes, not a path.** Every binary `body` field and every multipart file field is +typed `FileInput`, which is a `Blob`, a `Uint8Array`, an `ArrayBuffer`, a `ReadableStream` of bytes, +any async iterable of them — which is what a Node `Readable` is — or a `{ data, fileName?, +contentType? }` wrapper around one. A filesystem path is **not** a file body: open it with +`createReadStream(path)` to stream it, or `await openAsBlob(path)` to send it with a +`Content-Length`. A field declared as an array of files takes `FileInput[]`, and the SDK sends one +part per element under the shared field name; an empty array sends no part at all. + +Nothing about a file is schema-checked, so a wrong value throws at the call rather than rejecting on +the wire. The media type the SDK declares is a fallback: a `contentType` on the wrapper, or a +non-empty `Blob.type`, overrides it. A file name is yours or absent — the SDK never invents one, and a +part with no file name is read as an ordinary text field by many servers, so name a file you upload. + +**A binary response is a handle, not a value.** Such an operation resolves to a `BinaryContent`: +`stream`, `contentType`, and a `fileName` parsed from `Content-Disposition` where the server sent one. +Read the stream exactly once — `new Response(file.stream)` to buffer it, a reader or `for await` to +stream it — or release it with `await file.stream.cancel()` if you decide not to. No timeout covers +the read, so an unread stream holds the connection until you read it, cancel it or abort the call's +`signal`. A server-chosen file +name is untrusted input: sanitise it before writing to disk. + For the JSON case, the `body` field's type is a generated model — a plain object literal, no builders. -Required members must be set; optional ones are omitted from the JSON when left `undefined`. Take the +Required members must be set; optional ones are omitted from the JSON when left `undefined`, except one +with a spec default (`s.defaulted(...)` in the model's schema), which the SDK fills in and sends. Take the type's name and the file that declares it from the **Type sources** table: ```ts const result = await client.{resource}.{operation}({ body: { requiredProp: value, // required members must be provided - optionalProp: value, // optional; leave unset to omit from the request + optionalProp: value, // optional; unset means omitted, unless it has a spec default }, }); ``` @@ -173,7 +196,8 @@ Read the `Default` column **before** concluding the API dropped data — it was ### Every value is validated before anything is sent Each field is schema-encoded on the way out. A wrong type or a malformed format (a bad date string, -say) throws `SchemaError` and **nothing is sent** — no request reaches the network. That is an error in +say) rejects with an `EncodeError` — its `cause` the `SchemaError` naming the field — and **nothing is +sent**: no request reaches the network. That is an error in your code, not an API failure; see **typescript-error-handling**. ## Enums @@ -221,29 +245,33 @@ const outcome = await client.{resource}.{operation}({ /* ... */ }).asApiResult() if (outcome.ok) { console.log(outcome.status, outcome.headers.get("x-request-id"), outcome.value); } else { - console.log(outcome.status, outcome.errorMessage, outcome.error); // error is the PAYLOAD + console.log(outcome.status, outcome.message, outcome.payload); // the error's members, not the error } ``` ```ts type ApiResult = | { ok: true; status: number; headers: Headers; value: T } - | { ok: false; status: number; headers: Headers; errorMessage: string; error: PayloadOf }; + | { ok: false; status: number; headers: Headers; message: string; method: HttpMethod; uri: string; + payload: PayloadOf }; ``` -Four things to know: +Five things to know: - **`.asApiResult()` must be called on the value the operation returned.** `ApiPromise` overrides `Symbol.species`, so `.then()`, `.catch()` and `.finally()` hand back a plain `Promise` and the method is gone. `op(...).then(x => x).asApiResult()` does not compile. -- **`.asApiResult()` still rejects for transport-level failures** — a connection error, timeout, abort - or schema failure. It converts only the *HTTP error status* branch into a value, so it still needs a +- **`.asApiResult()` still rejects for every other failure** — a body that would not decode, a value that + would not encode, a connection error, a timeout, a credential that could not be obtained, or a caller + abort. It converts only the *HTTP error status* branch into a value, so it still needs a `try`/`catch` around it. -- **On failure, `outcome.error` is the payload, not the error object.** The `ResponseError` instance is - not reachable through this path. -- **An `ApiPromise` attaches a no-op `catch` to itself at construction**, so a call you never await - raises no unhandled-rejection warning. Fire-and-forget is therefore silent — never start a call you - do not consume. +- **On failure, `outcome` carries the error's own members, not the error object** — `status`, + `headers`, `message`, `method`, `uri` and `payload` — so `outcome.payload.kind` narrows exactly as + `err.payload.kind` would. The `ApiError` instance is not reachable through this path. +- **Nothing suppresses an unhandled rejection.** A call you never await that fails is reported by the + runtime — `unhandledRejection` on Node, which by default ends the process. `.asApiResult()` marks the + promise handled as its first act, so call it on the returned value in the same turn, and never start + a call you do not consume. - **Both paths read one underlying outcome**, so awaiting an `ApiPromise` *and* calling `.asApiResult()` on it issues no second request. The request goes out when the operation is called, not when you await it — which also means a call you build and hold has already been sent. @@ -263,7 +291,7 @@ you will meet are: the file under `src/models/` where you read its shape; the map does not duplicate it. - **A primitive** — a `text/plain` body decoded by the plain-text scalar decoder, not by a model. - **`undefined`** — the operation's success response has no body. That is a real value, not a mistake: - the decoder asserts the body is empty, and a non-empty one raises `SchemaError`. + the decoder asserts the body is empty, and a non-empty one rejects with `DecodeError`. **On an `undefined` operation, resolving *is* the success signal.** Do not bind the value and do not test it — success is "did not reject", exactly as for the others. Two follow-ons: if you need the @@ -273,16 +301,29 @@ resulting state, re-read it with a separate call; and if you need to tell a `200 Endpoints in the same family can differ, so let each operation's own **Returns** bullet decide how you read it. -## Cancellation +## Cancellation and per-call retry `RequestOptions` is the entire per-call surface: ```ts -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { + signal?: AbortSignal; + retry?: RequestRetryOptions; // Pick +}; +``` + +`retry` overrides three fields of the client's retry policy for this call alone, field by field — a +field you leave out keeps the client's value: + +```ts +await client.{resource}.{operation}({ /* ... */ }, { retry: { maxRetries: 0 } }); // one attempt +await client.{resource}.{operation}({ /* ... */ }, { retry: { timeout: 5_000 } }); // 5 s per attempt ``` -There is **no** per-call timeout, header, base URL, retry or auth override — those belong on -`ClientOptions` at construction (**typescript-client-initialization**). +There is **no** per-call header, base URL or auth override, and the type admits no other retry field +(though the runtime honours one it is handed) — those belong on +`ClientOptions` at construction (**typescript-client-initialization**), and the retry policy's other +fields on `ClientOptions.retry` (**typescript-configuration-resilience**). ```ts const controller = new AbortController(); @@ -292,19 +333,24 @@ await client.{resource}.{operation}({ /* ... */ }, { signal: controller.signal } ``` An already-aborted signal rejects immediately. See **typescript-configuration-resilience** for -per-call timeouts and for combining signals. +deadlines across a whole call and for combining signals. Three mechanics that decide how you catch the result: -- **Your signal and the client-wide `timeout` race the same internal controller**, so a call ends on - whichever fires first. `timeout` is the client's, in milliseconds, and there is no per-call form of it - — a signal plus your own timer is how you bound one call. -- **An abort rejects with the SDK's `AbortError`, and a timeout with `TimeoutError`** — not with the - reason you passed. Your `controller.abort(reason)` argument survives as the `AbortError`'s `cause`, - so read it there rather than expecting it at the top level. -- **Neither is a `ResponseError`.** A `catch` that narrows on the response-error type — or on an - operation's declared error class — will **not** match an abort, a timeout or a connection failure. - See **typescript-error-handling** for narrowing that covers both families. +- **Your signal and each attempt's `retry.timeout` race the same internal controller**, so an + attempt ends on whichever fires first. `retry.timeout` bounds **one attempt**, in milliseconds, even + when set per call — an idempotent call that keeps failing gets up to four of them, plus backoff — so + a signal plus your own timer is how you bound the whole call. An abort also cuts a backoff wait short, + and is never retried. +- **An abort rejects with the signal's own `reason`, unwrapped, and a timeout with `TimeoutError`** + once no retry is left. + Whatever you passed to `controller.abort(reason)` — or the `DOMException` a bare `abort()` supplies — + is what the call rejects with, so it is **not** an `{Api}Error`; the timeout is the SDK's own and + stays in the family. +- **Neither is an `ApiError`.** A `catch` that narrows on `ApiError` — or on an operation's declared + error class — will **not** match an abort, a timeout or a connection failure. See + **typescript-error-handling** for a ladder over the whole family, and for rethrowing what is outside + it. ## Concurrency @@ -320,8 +366,10 @@ const [a, b] = await Promise.all([ ``` **Bound it once the list is data rather than two literals.** `Promise.all` over an array opens as many -simultaneous requests as the array is long, against a provider that rate-limits — and since the SDK -performs **no retries**, the 429s that come back are lost calls, not delayed ones. Cap the width: +simultaneous requests as the array is long, against a provider that rate-limits. The SDK retries a +`429` on an idempotent call, honouring `Retry-After`, but every one of those calls then retries after +a similar wait — jitter spreads them by a quarter at most — keeping the pressure on, and a write +outside the retry gate simply fails. Cap the width: ```ts async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -347,7 +395,7 @@ returns a page, advance its page or cursor field yourself in a loop — see ```ts // Signature (illustrative — take the real one from map/operations/{resource}.md): // {operation}(request: {Resource}.{Operation}Request, options?: RequestOptions): -// ApiPromise<{Model}, ResponseError> +// ApiPromise<{Model}, ApiError> // // Fields: collectionId path string yes // status query {Enum} no @@ -367,7 +415,7 @@ const outcome = await client.{resource}.{operation}( ).asApiResult(); if (!outcome.ok) { - console.error(outcome.status, outcome.errorMessage, outcome.error); + console.error(outcome.status, outcome.message, outcome.payload); } else { for (const item of outcome.value.{items}) { console.log(item.id); @@ -383,4 +431,4 @@ if (!outcome.ok) { - Build bodies, unions, collections, dates, enums → **typescript-models** - Errors and status codes → **typescript-error-handling** -- Timeouts, retries, logging, base URL, paging → **typescript-configuration-resilience** +- Timeouts, the retry policy, logging, base URL, paging → **typescript-configuration-resilience** diff --git a/plugins/alpaca/skills/typescript/typescript-client-initialization/SKILL.md b/plugins/alpaca/skills/typescript/typescript-client-initialization/SKILL.md index d3765c7cb..8f8dcacf0 100644 --- a/plugins/alpaca/skills/typescript/typescript-client-initialization/SKILL.md +++ b/plugins/alpaca/skills/typescript/typescript-client-initialization/SKILL.md @@ -49,14 +49,15 @@ Two facts about where those files live: ## Shape of the client APIMatic TypeScript SDKs expose **one public client class** with **one constructor**, taking a single -partial options object: +options object: ```ts -constructor(clientOptions: Partial = {}) +constructor(options: ClientOptions = {}) ``` -Every option has a default, so `new {Api}Client()` compiles. The options object is the whole -configuration surface, and reading the environment into it is yours to do at the call site. +Nothing on it is required and neither is the argument, so `new {Api}Client()` compiles. The options +object is the whole configuration surface, and reading the environment into it is yours to do at the +call site. Operations are exposed on the client. Most are grouped under **resource getters** (one per API resource group) and called `client.{resource}.{operation}(...)` — for example, a `widgets` resource's @@ -67,42 +68,60 @@ getters (and any direct operations) come from the contract sheet (grounded in `s `src/resources/`) — not from the API's own documentation, and not from the compiled `dist/`. See **typescript-calling-endpoints**. -The options type always carries these four knobs (credential members vary per API — see +The options type always carries these five knobs (credential members vary per API — see **typescript-authentication**): ```ts export type ClientOptions = { - readonly serverEnvironment: ServerEnvironment; // selects the base URL - readonly serverOptions: ServerOptions; // per-server, per-environment baseUrl override - readonly timeout: number; // MILLISECONDS, per request. Default 60_000 - readonly fetch?: FetchLike | undefined; // the ONE extension point — + readonly serverEnvironment?: ServerEnvironment; // selects the base URL + readonly serverOptions?: ServerOptions; // per-server, per-environment baseUrl override + readonly retry?: RetryOptions | undefined; // retry policy + per-attempt timeout (MILLISECONDS). + // Retries ON by default + readonly logging?: LoggingOptions | undefined; // request/response logging — OFF by default + readonly fetch?: FetchLike | undefined; // the ONE transport extension point — // see typescript-configuration-resilience // + one OPTIONAL credential member per auth scheme the API declares, and — for an OAuth2 scheme — // a {scheme}Strategy member alongside it (see typescript-authentication) }; ``` -Those four names are minted by the generator and cannot move; a credential spelled like one of them is -the member that takes a suffix instead. **Every member is `readonly`**, and the constructor merges -your partial over `DEFAULT_CLIENT_OPTIONS` exactly once — there is no mutable accessor and no way to -reconfigure a built client. `DEFAULT_CLIENT_OPTIONS` is exported from the package root and holds the -concrete defaults for your SDK — read it rather than trusting the numbers above. Note in particular -that the default `serverEnvironment` is the **first** environment the spec declares, which is not -necessarily the production one. - -**`fetch` is not a niche escape hatch — it is the only one there is.** The SDK performs **no retries** -and **no logging**, and carries **no hooks, middleware or interceptors**, **no pagination** and **no -streaming**. Each of those is something you build by wrapping `fetch` — including a header you want on -every request. **typescript-configuration-resilience** owns that work and carries the wrappers. Two -consequences to take into any first call: **a failed call rejects once**, and **nothing is logged -anywhere** (`src/core/` contains no `console` call), so an integration is silent until you make it -otherwise. A replacement `fetch` **must forward `init.signal`** to whatever performs the request; -drop it and both cancellation and `timeout` go inert. - -`timeout` has its own trap: it is in **milliseconds**, it bounds **one request**, and a non-finite or -non-positive value is **not** "no timeout" — the transport (`src/core/raw-client.ts`) falls back to -its own ceiling, which is *longer* than the default, and clamps anything above what a timer can hold. -There is no per-request override: the entire per-request surface is `RequestOptions` = `{ signal }`. +Those five names are minted by the generator and cannot move; a credential spelled like one of them is +the member that takes a suffix instead. **Every member is `readonly`**, and the client captures your +options once, at construction — a different configuration means a new client. Each default is +applied where the value is read: a server variable takes the `@default` on its member in +`src/client-options.ts`, `fetch` takes its in `src/core/client-options.ts`, every `retry` field +takes its in `src/core/retry.ts`, and every `logging` field its in `src/core/logging.ts`. Read them +there rather than trusting the numbers on this page. +Note in particular that the default `serverEnvironment` is the **first** environment the spec +declares, which is not necessarily the production one. + +**Retries are on by default.** With `retry` left out, a `GET`, `HEAD`, `PUT` or `OPTIONS` call that +gets a `408`, `429`, `500`, `502`, `503` or `504`, or loses its connection, or times out, is re-sent +up to **3** more times with exponential backoff and jitter, honouring `Retry-After`. A `POST`, +`PATCH` or `DELETE` is sent once unless you name its method in `httpMethodsToRetry`. Every one of +those numbers is a `RetryOptions` field you can change, and `retry: { maxRetries: 0 }` turns retrying +off. So **a failed idempotent call surfaces only after its retries are spent** — seconds later than the +first failure, not at it. **typescript-configuration-resilience** owns the policy: the field table, +what is and is not retried, and per-call overrides. + +**`fetch` is not a niche escape hatch — it is the only transport seam there is.** The SDK carries +**no hooks, middleware or interceptors**, **no pagination** and **no streaming**. Each of those is +something you build by wrapping `fetch` — including a header you want on every request. +**typescript-configuration-resilience** carries the wrappers. **Logging is built in and off by +default**: `logging` turns it on, and so does the `{APICLIENTTYPENAME}_LOG` environment variable (the +client class name in upper snake case, plus `_LOG`) when code sets neither a `logger` nor a `level` — so a +deployment can start writing request lines with no code change. `retry.onRetry` is the one built-in +callback, and it sees only retries. A replacement `fetch` is called with one `Request` and no `init`, +and **must forward that `Request`, or its `signal`,** to whatever performs the request; drop the signal +and both cancellation and every attempt's timeout go inert. + +`retry.timeout` has its own trap: it is in **milliseconds**, it bounds **one attempt** — up to +its response headers — not the whole call, and a value the platform cannot honour is **not** "no timeout" — it is +resolved once when the client is built, and a negative, fractional, `NaN`, `Infinity` or out-of-range +value silently falls back to the default (`60_000`) rather than failing. A usable value reaches the +timer verbatim, so `timeout: 0` aborts every attempt instead of lifting the deadline. A single call +may override `maxRetries`, `timeout` and `statusCodesToRetry`, and its log level, through +`RequestOptions` = `{ signal, retry, logging }`; nothing else is per call. ## Direct instantiation @@ -111,7 +130,7 @@ import { {Api}Client, ServerEnvironment } from "{package-name}"; const client = new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, // pick the environment your API exposes - timeout: 10_000, + retry: { timeout: 10_000 }, // per attempt; the retry defaults stand // ...set the credential member your API uses (see typescript-authentication) }); ``` @@ -121,15 +140,16 @@ where the client is built rather than inherited silently. ### What construction checks, and what it does not -A clean construction does not mean a working client. **Exactly two things throw from the -constructor**, both `SdkError`: no reachable `fetch` implementation, and a basic-auth username -containing `:`. Everything else is accepted and fails later, one layer from its cause: +A clean construction does not mean a working client. **What throws from the constructor** is +`ConfigurationError` — no reachable `fetch` implementation, a basic-auth username containing `:`, or a +`serverEnvironment` the SDK does not know — and `SchemaError`, for a `serverOptions` override whose +value its schema refuses: every server group is resolved as the client is built. Everything else is +accepted and fails later, one layer from its cause: | Passed at construction | When it actually fails | | --- | --- | -| a non-finite or non-positive `timeout` | never — it silently becomes the transport's ceiling, which is *longer* than the default. There is no `ValueError` equivalent here | +| a `retry` field out of range — a negative or fractional `timeout`, a `maxJitter` above `1`, a lower-cased `"get"` in `httpMethodsToRetry` | never — that field silently falls back to its default. `timeout: 0` is the exception: it is kept, and aborts every attempt. There is no `ValueError` equivalent here | | a `baseUrl` override | first call — as a connection error, or as a `401` against the wrong host | -| a `serverEnvironment` widened out of the union (a `string` cast) | first call **that touches that server group** — the resolvers are lazy arrows, so it is `SdkError: Unknown server environment`, not a construction failure | | wrong credentials | first call — and under OAuth2, first *token* request, so the error names the token endpoint rather than the operation you wrote | | a misspelled or extra credential field | never at run time. Credentials are plain objects checked only by TypeScript; nothing re-validates them at construction | | **an omitted** credential | first call, as a `401`. An unset credential configures the client for **no auth** — the operation sends no credential rather than erroring. See **typescript-authentication** | @@ -147,16 +167,15 @@ constructor resolved and cached. configuration; its constructor resolves the rest and then owns it: - the **transport** — a `RawClient` built over the resolved `fetch` implementation. If no `fetch` is - reachable, **the constructor throws `SdkError`**, not the first call; -- the **server resolvers** — `buildServers(serverEnvironment, serverOptions)` runs here, closing - over the environment and over each group's options object. The environment is fixed for the - client's life, so assigning `serverEnvironment` afterwards does nothing; the per-environment - fields are re-merged inside the resolver on **every request**, so mutating one on the object - you passed in does leak into later calls. Never rely on that: it races in-flight calls. - Configure the server before you construct, and construct a new client to change environment. + reachable, **the constructor throws `ConfigurationError`**, not the first call; +- the **server groups** — `buildServers(options)` runs here and resolves every group **once**: the + environment and each override are read and decoded as the client is built, and every call afterwards + only attaches its own sub-path. So assigning `serverEnvironment` or mutating an override on the + object you passed in does nothing, and a bad value throws from the constructor rather than from a + call. Configure the server before you construct, and construct a new client to change environment. See **typescript-configuration-resilience**, *What is captured when*; - the **auth schemes** — validated at construction (a basic-auth username containing `:` throws - `SdkError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token + `ConfigurationError` from the constructor), and for an OAuth2 SDK the auth scheme *is* the access-token cache, closed over by the scheme object. That last one is the reason a per-request client is a real cost rather than a stylistic one: a fresh @@ -186,10 +205,11 @@ then stored). Two consequences that fail silently: - **Monkey-patching `globalThis.fetch` after a client exists has no effect on that client.** A test that swaps the global and reuses a module-level client is still calling the original. Pass the fake through `ClientOptions.fetch` and build the client after — see **typescript-testing**. -- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(url, init)` with `this` set to - `undefined`, so a transport method that depends on its receiver must be bound before you pass it. - It must forward `init.signal` and resolve to a `Response` the decoder can read `status`, `headers` - and a body from. **typescript-configuration-resilience** owns the wrappers. +- **A replacement must satisfy `typeof fetch`** — it is called as `fetch(request)`, one `Request` and + no `init`, with `this` set to `undefined`, so a transport method that depends on its receiver must be + bound before you pass it. It must forward that `Request`, or its `signal`, and resolve to a + `Response` the decoder can read `status`, `headers` and a body from. + **typescript-configuration-resilience** owns the wrappers. ## Choosing the server / base URL @@ -271,9 +291,9 @@ export function build{Api}Client(): {Api}Client { return new {Api}Client({ serverEnvironment: ServerEnvironment.{Environment}, - // SET THIS. Default 60_000 ms. Bounds ONE request, and there are no retries. - // See typescript-configuration-resilience > Timeout. - timeout: 10_000, + // SET THIS. Default 60_000 ms. Bounds ONE attempt, and an idempotent call gets up to + // 4 of them. See typescript-configuration-resilience > Retries. + retry: { timeout: 10_000 }, {scheme}: token, }); @@ -281,9 +301,9 @@ export function build{Api}Client(): {Api}Client { ``` Both flagged lines are load-bearing and they answer different failures: the credential check turns a -silent `401` into a startup failure, and `timeout` stops a hung provider from holding the caller open -for the full default minute with nothing retrying behind it. Setting only the timeout is the common -mistake — it looks like the configuration box is ticked. +silent `401` into a startup failure, and `retry.timeout` stops a hung provider from holding the caller +open for the full default minute on every attempt — up to four of them on an idempotent call. Setting +only the timeout is the common mistake — it looks like the configuration box is ticked. Then register the result once, however your framework shares singletons, and inject it: diff --git a/plugins/alpaca/skills/typescript/typescript-configuration-resilience/SKILL.md b/plugins/alpaca/skills/typescript/typescript-configuration-resilience/SKILL.md index b37858f55..480ee8e72 100644 --- a/plugins/alpaca/skills/typescript/typescript-configuration-resilience/SKILL.md +++ b/plugins/alpaca/skills/typescript/typescript-configuration-resilience/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-configuration-resilience -description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the four ClientOptions fields, server/base-URL selection and template variables, timeouts and cancellation, proxies, TLS and connection pooling, and everything the SDK does not do for you (retries, backoff, logging, pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that nothing retries, that nothing is logged, that the default environment is whichever the spec listed first, that a `fetch` replacement dropping `init.signal` disables the timeout, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. +description: Client configuration and resilience for an APIMatic-generated TypeScript SDK — the five ClientOptions fields, server/base-URL selection and template variables, the retry policy (on by default — what it retries, what it never does, per-call overrides) and the per-attempt timeout, cancellation, the built-in logging (off by default — levels, redaction, the environment variable), proxies, TLS and connection pooling, and what the SDK does not do for you (pagination, streaming) built on the one `fetch` seam. Load before you construct or tune the client — the field list alone does not reveal that idempotent calls are retried by default and writes are not, that the timeout bounds one attempt rather than the call, that an environment variable turns logging on with no code change, that the default environment is whichever the spec listed first, that `fetch` is handed one `Request` it must forward, that Node's fetch ignores HTTP_PROXY, or that a hand-written page loop is unbounded by default. --- # Configuration & resilience for an APIMatic TypeScript SDK @@ -16,10 +16,14 @@ description: Client configuration and resilience for an APIMatic-generated TypeS > class, declared in `src/client.ts`), `{resource}`/`{Resource}`, `{operation}`/`{Operation}`, > `{group}`, `{environment}`, `{variable}`, `{items}`, `{nextCursor}`. Replace each with the concrete > identifier from the source; none of them is a name the generator emits literally. +> +> `{APICLIENTTYPENAME}_LOG` is the logging environment variable: the client class name in upper snake +> case, plus `_LOG`, so `AcmeClient` reads `ACME_CLIENT_LOG`. The getting-started skill's identity table +> names yours. Everything configurable is a field on `ClientOptions` (`src/client-options.ts`), passed to the one constructor. There is no `Configuration` class, no builder, and no per-request override beyond -`{ signal }`. Server groups, environments and their URL templates live in `src/servers.ts`. +`{ signal, retry, logging }`. Server groups, environments and their URL templates live in `src/servers.ts`. Everything under `src/core/` is vendored static code that is **byte-identical in every generated TypeScript SDK**, so the engine behaviours below hold without checking your SDK. @@ -29,12 +33,13 @@ TypeScript SDK**, so the engine behaviours below hold without checking your SDK. | --- | --- | --- | --- | | `serverEnvironment` | `ServerEnvironment` | first declared | selects which environment's server options are read | | `serverOptions` | `ServerOptions` | `{}` | per-group, per-environment `baseUrl` and template-variable overrides | -| `timeout` | `number` (**ms**) | `60_000` | per-request timeout | -| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one extension point** | +| `retry` | `RetryOptions` | retries **on** — see *Retries* | the retry policy, and `retry.timeout` (**ms**, `60_000`) bounding each attempt | +| `logging` | `LoggingOptions` | **off** — see *Logging* | the built-in request/response logger, its levels and its redaction | +| `fetch` | `FetchLike` (`typeof fetch`) | global `fetch` | **the one transport extension point** | | `{scheme}` / `{scheme}Strategy` | per scheme | unset | credentials — see **typescript-authentication** | -Read `DEFAULT_CLIENT_OPTIONS` (exported from the package root) rather than trusting the defaults -column: the credential members and the default environment are per-SDK. +Read the defaults off your own SDK rather than trusting the column: the credential members and the +default environment are per-SDK. ## Not on this SDK @@ -42,14 +47,12 @@ Absent **by design**, not undocumented. This list ships with `src/core/` and is | You might reach for | Reality | | --- | --- | -| `maxRetries`, backoff, `Retry-After` handling | no retries. A failed call rejects once | -| a logger, `logLevel`, request/response logging | none. `src/core/` contains no `console` call | -| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one extension point | +| hooks, middleware, interceptors, `onRequest`/`onResponse` | none. `fetch` is the one transport extension point; `retry.onRetry` is the one callback, and it sees only retries | | pagination, `for await`, auto-paging helpers | no operation is paginated and nothing is async-iterable | -| SSE, `text/event-stream`, `ReadableStream` | no streaming. Every decoder reads the body to completion | -| `FormData`, `Blob`, `File`, multipart, binary bodies | none. Body kinds are empty, JSON, form-urlencoded, text | -| per-request `headers`, `timeout`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal }` | -| an injected `Idempotency-Key` header | nothing is injected. A key on the wire is one **you** put there | +| SSE, `text/event-stream` | no event streams. Every decoder reads the body to completion, bar a binary success, which hands its stream over unread | +| multipart responses, XML bodies | none. Body kinds are empty, JSON, form-urlencoded, text, multipart and binary | +| per-request `headers`, `baseUrl`, idempotency key | none. `RequestOptions` is `{ signal, retry, logging }`; `retry` takes only `maxRetries`, `timeout` and `statusCodesToRetry`, and `logging` only `level` | +| a circuit breaker, a rate limiter | none. The retry loop honours `Retry-After`, but nothing limits how many calls you start | | the raw `fetch` `Response` | deliberately unreachable. `status`/`headers` are on `asApiResult()` and on `ResponseError` | Everything in the left column that you actually need, you build on `fetch`. The rest of this skill is @@ -57,8 +60,9 @@ those patterns and the traps in them. **What the SDK does own**, so that you do not rebuild it: URL and server resolution, request encoding and schema validation on the way out, auth application and OAuth token caching, response -decoding, the two-family error mapping, and one timeout. Connection pooling belongs to `fetch`, not -to the client. +decoding, the two-family error mapping, the `Idempotency-Key` on every non-GET call, the retry +loop with its per-attempt timeout, and request/response logging with redaction. Connection pooling +belongs to `fetch`, not to the client. ## The `fetch` seam @@ -66,22 +70,36 @@ to the client. type FetchLike = typeof fetch; ``` -One rule, and it is the one that breaks things when missed: **forward `init.signal`**. Spreading -`...init` does it. Drop it and both the client `timeout` and per-call cancellation go inert — the call -neither aborts nor times out, because the transport enforces both through the signal it puts on `init`. +The SDK calls it with **one argument, the attempt's `Request`, and no `init`**. The method, the +headers (the credential included), the body and the signal are all on that `Request`. One rule, and it +is the one that breaks things when missed: **forward that `Request`, or at least its `signal`**, to +whatever performs the request. Drop the signal and both each attempt's `retry.timeout` and per-call +cancellation go inert — the call neither aborts nor times out, because the transport enforces both +through that signal. A wrapper written against `(input, init)` still type-checks, but `init` is always +`undefined`: whatever it reads from `init` is nothing, and whatever it passes as `init` **replaces** +what the `Request` carries, so `fetch(input, { headers })` drops every header the SDK set. On an abort +a replacement may reject with whatever it likes: the SDK reads the reason off the signal it built the +`Request` with, so a deadline is still a `TimeoutError` the retry loop retries, and a caller abort +still rejects with the caller's own `reason`. -Three facts that decide how you write a wrapper: +Four facts that decide how you write a wrapper: - **It sees every request the client makes, including the OAuth token request.** The auth schemes are built over the same transport as the operations (`buildAuthSchemes(options, servers, rawClient)` in - the constructor), so a logging wrapper logs the token POST — `client_secret` and all — and a retry - wrapper is a candidate to resend it. Filter by URL where that matters. + the constructor), so a wrapper that logs requests logs the token POST — `client_secret` and all. The + built-in logger (*Logging*) masks it; a hand-rolled one does not. Filter by URL where that matters. +- **It sits under the retry loop, so it sees every attempt.** A call retried three times is four + requests through your wrapper, each a fresh `Request` with its own signal, and a wrapper that logs + writes four lines. To see one line per logical call, log around the SDK call instead; to see why an + attempt was abandoned, use `retry.onRetry` or the built-in logger's retry line. - **A plain `Error` thrown from a wrapper reaches the caller as `ConnectionError` with your error on - `.cause`.** An `{Api}Error` subclass (`SdkError`, `TimeoutError`, …) passes through unwrapped. -- **Wrappers compose by nesting, innermost-first at the network:** - `new {Api}Client({ fetch: loggingFetch(retryingFetch(3)) })`. Put logging **outside** retry to see one - line per attempt, inside to see one line per logical call. Order is load-bearing for a write guard — - see *Making a write safe under retries*. + `.cause` — and is retried like a dropped connection** for a method in the retry gate, so a + deliberate refusal is thrown again on every attempt and surfaces only after the backoff. A member of + the `{Api}Error` family (`TimeoutError`, `ConnectionError`, …) passes through unwrapped, and is + retried only if its `kind` is `connection` or `timeout`. +- **Wrappers compose by nesting, innermost-first at the network:** in + `new {Api}Client({ fetch: tracingFetch(proxiedFetch) })` the tracer sees the request before the + proxy dispatches it. Do not add a retrying wrapper to the stack — see *Retries*. ## `serverOptions` configuration for each environment @@ -128,20 +146,18 @@ Four things about how this resolves: your value is silently ignored in favour of the selected one's default. - **`baseUrl` is a template, not a URL.** Variables are percent-encoded into their `{placeholder}` at request time. A placeholder in a `baseUrl` you supply is only substituted if a declared variable of - that exact name exists — otherwise it survives into the URL verbatim, and there is no `SdkError` for - it the way there is for an unfilled path parameter. -- **An environment value the SDK does not know throws `SdkError` when a server is resolved** — at the - first call, not at construction. It is the one failure on this surface that throws **synchronously** - out of the operation method, so `try`/`await` catches it but `.asApiResult()` and `.catch()` never - see it. + that exact name exists — otherwise it survives into the URL verbatim, and nothing rejects it the way + an unfilled path parameter is rejected. +- **An environment value the SDK does not know throws `ConfigurationError` from the constructor** — + every server group is resolved once, as the client is built, so no operation method throws + synchronously. ### ⚠▶▶ Always pass `serverEnvironment` explicitly `serverEnvironment` has a default, so `new {Api}Client()` compiles and reaches a real host. **That -default is whatever environment the spec listed first** — read it off -`DEFAULT_CLIENT_OPTIONS.serverEnvironment` in `src/client-options.ts`, a literal -`ServerEnvironment.{Environment}`, and always the first member `ServerEnvironment` declares in -`src/servers.ts`. For many providers the spec lists sandbox first. Nothing announces it. +default is whatever environment the spec listed first** — the first member `ServerEnvironment` +declares in `src/servers.ts`, and the one whose arm every resolver in that file labels `case +undefined:` alongside its own. For many providers the spec lists sandbox first. Nothing announces it. A deployment that believes it configured production and did not gets sandbox behaviour with production credentials, which fails auth in a way that reads like a credentials problem rather than an @@ -162,23 +178,21 @@ const serverEnvironment = ENVIRONMENTS[process.env.API_ENV ?? ""]; if (!serverEnvironment) throw new Error(`unknown API_ENV: ${process.env.API_ENV}`); ``` -Without the guard, a typo in a deployment variable becomes `undefined`, `{ ...DEFAULT_CLIENT_OPTIONS, -serverEnvironment: undefined }` overwrites the default with `undefined`, and the failure surfaces at -the first call as a synchronous `SdkError` about an unknown environment (above) — a long way from the -variable that caused it. +Without the guard, a typo in a deployment variable becomes `undefined`, and an `undefined` +environment is read as *none selected*: every resolver's default arm answers to `case undefined:` +alongside its own label, so the client runs quietly against the default environment instead of the +one you asked for. Nothing throws, and nothing is logged. An unknown *string* is the loud case — the +constructor rejects it with `ConfigurationError` (above) — so it is the value your own +configuration turns into `undefined` that fails silently, which is what the guard is for. ### What is captured when -`serverEnvironment` and `serverOptions` are not read at the same time, which makes one of them look -inert. The constructor calls `buildServers(options.serverEnvironment, options.serverOptions)` **once**, -capturing the environment **and each group's options object**; the resolvers it returns re-merge that -group object's per-environment fields on **every request**. So mutating a nested field of the -`serverOptions` object you passed in does take effect on later calls, while replacing a whole group -(`serverOptions.{group} = …`) or assigning `serverEnvironment` afterwards does not. - -Do not use that. Mutating server options on a live client is an unsynchronised race against in-flight -calls, not a supported "switch hosts" operation. **Configure the server before you construct, and -construct a new client to change environment.** +`serverEnvironment` and `serverOptions` are both read **once**, as the client is built. The constructor +calls `buildServers(options)`, which resolves every server group — the environment, its `baseUrl` and +each variable, decoded through its schema — into a value that every call afterwards only attaches its +own sub-path to. So mutating the `serverOptions` object you passed in, replacing a whole group +(`serverOptions.{group} = …`), or assigning `serverEnvironment` afterwards has no effect on the client. +**Configure the server before you construct, and construct a new client to change environment.** ### Redirecting at a mock @@ -189,45 +203,153 @@ production. Override every group the run touches. See **typescript-authenticatio This is a real base-URL override, not a proxy — the SDK builds requests against the URL you give it. For a proxy, see *Proxy or custom agent* below. -## Retries — the SDK has none, so this is yours +## Retries — on by default -A failed call rejects once. There is no `maxRetries`, no backoff, no `Retry-After` handling, and -nothing observes a `429`. Concretely: +**A client built without `retry` retries.** With the defaults: -- A `429` or `503` rejects on the first attempt. Nothing is resent. -- A connection reset rejects. Nothing is resent. -- **Even the one thing that looks automatic — a `401` — is not a retry.** The transport invalidates - whatever that operation's auth scheme had cached, so the *next* call re-acquires; the failing request - is not resent. You see one `401`, then recovery. +- A `GET`, `HEAD`, `PUT` or `OPTIONS` call that gets a `408`, `429`, `500`, `502`, `503` or `504`, or + whose attempt fails with a `ConnectionError` or a `TimeoutError`, is re-sent up to **3** more times, + backing off roughly 1 s, 2 s and 4 s, and honouring `Retry-After`. +- A `POST`, `PATCH` or `DELETE` is sent **once**, whatever went wrong. The method gate covers every + failure, faults included. +- **A `401` is not in the default status set.** The transport invalidates whatever that operation's + auth scheme had cached, so the *next* call re-acquires; the failing call rejects. You see one `401`, + then recovery. -Two implications, and the second is easy to give away by accident: +The loop lives in `src/core/retry.ts`, and `src/core/raw-client.ts` runs every call through it. +Configure it with any subset of `RetryOptions` — a field you leave out keeps its default: -1. **Whatever retrying your integration needs, you write.** There is no knob. -2. **You inherit no accidental duplicate writes.** A failed write was attempted exactly once at the SDK - layer. That is a genuinely valuable property — do not discard it carelessly when you add retries. +```ts +new {Api}Client({ retry: { maxRetries: 0 } }); // off: every call is sent exactly once +new {Api}Client({ retry: { timeout: 10_000 } }); // the defaults, with a tighter per-attempt budget +new {Api}Client({ + retry: { + maxRetries: 5, + httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "POST"], // see *Making a write safe* + }, +}); +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `timeout` | `60_000` | Milliseconds **one attempt** may take, up to its response headers. Not a budget for the call | +| `statusCodesToRetry` | `[408, 429, 500, 502, 503, 504]` | Exactly these and no others — there is no family rule, so `[429, 503]` stops retrying `500`. `[]` retries no status, while faults still retry | +| `httpMethodsToRetry` | `["GET", "HEAD", "PUT", "OPTIONS"]` | The gate: a call outside it is never re-sent. `[]` turns retrying off. Upper case only | +| `maxRetries` | `3` | Retries *after* the first attempt, so `3` is up to four sends. `0` turns retrying off | +| `delay` | `1000` | Milliseconds before the first retry, and the base the curve grows from | +| `backoffFactor` | `2` | What each successive delay is multiplied by, between `1` and `100` | +| `useExponentialBackoff` | `true` | `false` repeats `delay` unchanged | +| `maxJitter` | `0.25` | A **fraction**, `0` to `1`, and additive: a wait lands between its delay and `delay × (1 + maxJitter)`. `0` makes every wait exact | +| `onRetry` | none | `(attempt: RetryAttempt) => void`, called before each retry's wait | + +`0`, `false` and `[]` are values, never misses. Anything else a field cannot honour — a negative or +fractional `timeout`, a `maxJitter` of `50`, a list carrying a lower-cased `"get"` — **silently falls +back to that field's default**, and the client still constructs. `timeout: 0` is kept: it is a +zero-length deadline that fails every attempt, not "no timeout". + +The wait before retry *n* (counting from 0) is +`min(Retry-After ?? delay × backoffFactor ** n, 60_000) × (1 + random × maxJitter)`, rounded up. The +60-second ceiling is the engine's, not an option, and jitter sits on top of it, so the longest single +wait is 75 s at the defaults. + +What the loop does and does not do — read these before you rely on it: + +- **The method gate covers every failure.** A status in the set, a dropped connection, a timeout: each + is retried only for a method in `httpMethodsToRetry`. `PUT` is in the default gate — see *Making a + write safe*. +- **`Retry-After` is honoured** in both its delta-seconds and HTTP-date forms, capped at 60 s, with + jitter added on top — never subtracted, so the SDK never wakes before the time the server named. A + malformed value falls back to the curve. +- **A caller abort is never retried.** Aborting your `signal` cuts a backoff wait short and rejects + with the signal's own `reason`. +- **A streaming request body is never retried**, whatever the method, because a stream cannot be + replayed. +- **A failure while obtaining a credential is not retried.** It ends the call — once the token + request's own retries are spent, since it is a `POST` under this same policy and retries only when + `httpMethodsToRetry` names `POST`. +- **A rejection from your `fetch` that is not an SDK error counts as a dropped connection.** It + becomes a `ConnectionError` and is retried — including a refusal your own wrapper throws on purpose + (see *The `fetch` seam*). The exception is one that arrives after the attempt's timeout or your + signal fired: the SDK reads the reason off the signal it built, so a caller abort rejects as itself + and is not retried, and a timeout becomes a `TimeoutError` and is retried like any other. What the + replacement rejected with does not change either. +- **A retried-away response is released** — its body's cancel started, not awaited, before the wait, + so a cancel that stalls never holds up the retry — and only its headers reach `onRetry`. When the last attempt fails, the call rejects with **that** attempt's failure: an + `ApiError` (or the operation's subclass) for a status, a `ConnectionError` or `TimeoutError` for a + fault — the same shapes as with retrying off (**typescript-error-handling**). `.asApiResult()` sees + the last response. + +**Do the arithmetic before you accept the defaults.** `retry.timeout` bounds each attempt, not the call, +so an idempotent call's worst case is `(maxRetries + 1) × timeout` plus the waits — four minutes at the +defaults. Nothing in the SDK imposes a wall-clock ceiling on the call; *Bounding a call* below is how +you impose one. + +### Per-call overrides + +Three fields can be overridden on one call through `RequestOptions.retry`, typed `RequestRetryOptions`. +Each is merged field by field over the client's policy, so a field you omit keeps the client's value: -### The two seams, and they are not equivalent +```ts +await client.{resource}.{operation}(request, { retry: { maxRetries: 0 } }); // this call: one attempt +await client.{resource}.{operation}(request, { retry: { timeout: 5_000 } }); // this call: 5 s per attempt +await client.{resource}.{operation}(request, { retry: { statusCodesToRetry: [503] } }); // this call: a narrower set +``` -A retry can sit in two places, and which you pick changes what it can see and what it re-runs: +The type admits only these three. How many attempts *this* call gets, how long each may take, and which +statuses are worth a second try vary by call site; the backoff curve, the method gate and `onRetry` are +how the client paces itself against one server, so they are set on the client alone. A per-call +`httpMethodsToRetry` would be a second route to retrying a write. The restriction is the type's +alone: an object carrying another `RetryOptions` field — from JavaScript, or through a constant typed +as the wider record — still has it honoured, so do not rely on the type to keep a write out of the +gate for one call. -| | **Transport-level** — wrap `fetch` | **Caller-level** — wrap the SDK call | -| --- | --- | --- | -| Sees | the raw `Response`, so `status` and `Retry-After` directly | typed rejections only — `ResponseError`, `TimeoutError`, `ConnectionError` | -| Re-runs | one HTTP round-trip | the whole operation: encoding, auth resolution, decoding | -| Sees the token request | **yes** — it goes through the same transport | no | -| Client `timeout` applies | per attempt | per attempt (the wrapper adds no bound of its own) | -| Good for | `Retry-After`, per-status policy, anything shared by every operation | per-operation policy, a decision that depends on the decoded error payload | +### Observing retries -**Prefer transport-level** for a blanket policy — it is the only place `Retry-After` and the response -headers are reachable without `.asApiResult()`, and it applies to every operation by construction. Use -caller-level when the decision depends on which operation it is or on the typed error payload: +`onRetry` is the one built-in callback. It runs before each retry's wait — not for the first attempt, +and not on success — with the retry about to be made: ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +const client = new {Api}Client({ + retry: { + onRetry: ({ attemptNumber, delay, reason }) => { + if (reason.kind === "status") log.warn({ attemptNumber, delay, status: reason.status }, "retrying"); + else log.warn({ attemptNumber, delay, kind: reason.error.kind }, "retrying after a fault"); + }, + }, +}); +``` -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +Narrow `RetryReason` on `kind` before reading the rest: a `"status"` carries the `status` and the +discarded response's `headers`, a `"fault"` carries the `connection` or `timeout` error and no response +at all. A fault from an OAuth token request carries the token endpoint's `uri`, not the operation's. +**Keep the callback to logging and metrics** — a throw from it ends the call with the error it threw. +With the built-in logger on at `warn` or below, the SDK writes its own retry line before `onRetry` +runs, so you need the callback only for metrics or a sink of your own. + +### When the SDK's policy is not enough + +Rules to hold to: + +- **Retry idempotent reads freely; treat every write as a separate decision.** A write that timed out + may have succeeded — a reset after the bytes reached the server is indistinguishable from one before. + The default gate encodes exactly this. +- **Do not stack a second retry layer** — a retrying `fetch` wrapper, a `withRetry` around the call, a + job that re-runs on failure — on top of the SDK's without turning the SDK's off for that path. + Attempts multiply (four inside four is sixteen round-trips, and up to sixteen times the timeout), and + every attempt of a caller-level layer is a new SDK call that mints a **new** `Idempotency-Key`. +- **Never retry an `EncodeError`, a `DecodeError`, an `AuthError` or a 4xx other than `408`/`429`.** On + an `EncodeError` nothing was sent at all — the fix is in your code. A `DecodeError` means the + response was malformed, which a resend rarely cures. An `AuthError` means a credential could not be + *obtained*, which is configuration, not weather. The loop retries none of them, and neither should + you. See **typescript-error-handling**. + +When a decision genuinely needs something the policy cannot express — a retry that depends on the +decoded error payload, or on which operation it is — turn the SDK's loop off for that call and own +retrying entirely at the caller: + +```ts +import { {Api}Error } from "{package-name}"; -// Caller-level: narrow on the typed error, and never retry what cannot succeed twice. async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { @@ -240,119 +362,27 @@ async function withRetry(work: () => Promise, attempts = 3): Promise { } function isTransient(err: unknown): boolean { - if (err instanceof ResponseError) return RETRY_STATUSES.has(err.status); - if (err instanceof {Api}Error) return err.kind === "connection" || err.kind === "timeout"; - return false; // SchemaError, AuthError, SdkError, anything else: no + if (!(err instanceof {Api}Error)) return false; // an abort's reason, a configuration fault, a bug: no + return err.kind === "api" && isRetryableConflict(err); // your decision, on the decoded payload } + +await withRetry(() => client.{resource}.{operation}(request, { retry: { maxRetries: 0 } })); ``` -`{Api}Error` is the base of every SDK error. The runtime declares it as `CoreError`, and the +`{Api}Error` is the whole error family, narrowed on its closed `kind`. The runtime declares it as `CoreError`, and the package exports it **only** under the SDK-branded alias — read the real name off the `CoreError as …Error` line in `src/index.ts`. See **typescript-error-handling**. -**Never retry a `SchemaError` or a 4xx other than `429`.** On an outbound `SchemaError` nothing was -sent at all — the request never reached the network, so a resend cannot change the outcome; the fix is -in your code. On an inbound one the response was malformed, which a resend rarely cures. And an -`AuthError` means a credential could not be *obtained*, which is configuration, not weather. See -**typescript-error-handling**. - -Do not stack both seams without meaning to: three transport attempts inside three caller attempts is -nine round-trips and up to nine times the `timeout`. - -### Building the transport-level wrapper - -Everything a retry policy needs, you decide and write: - -| What a policy needs | What the SDK gives you | Sane starting point | -| --- | --- | --- | -| which statuses to retry | nothing | `408, 429, 500, 502, 503, 504` | -| which methods to retry | nothing | `GET, HEAD, PUT, DELETE, OPTIONS` — idempotent only | -| attempt count | nothing | 3 total | -| backoff curve | nothing | exponential from ~500 ms, **with jitter** | -| `Retry-After` | nothing | honour it, in both its forms | -| not retrying a cancel | nothing | check `init.signal.aborted` first | - -The reference implementation: - -```ts -const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // as above -const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); - -function retryingFetch(maxAttempts = 3, inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const method = (init?.method ?? "GET").toUpperCase(); - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await inner(input, { ...init }); // forwards init.signal - - if (!RETRY_STATUSES.has(response.status) || !IDEMPOTENT.has(method) || attempt === maxAttempts) { - return response; - } - await delay(backoffFor(attempt, response), init?.signal); - } catch (err) { - // A caller abort or the client timeout must NOT be retried. - if (init?.signal?.aborted) throw err; - if (!IDEMPOTENT.has(method) || attempt === maxAttempts) throw err; - lastError = err; - await delay(backoffFor(attempt), init?.signal); - } - } - throw lastError; - }; -} - -function backoffFor(attempt: number, response?: Response): number { - const retryAfter = response?.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000); - const at = Date.parse(retryAfter); // Retry-After may be an HTTP-date - if (!Number.isNaN(at)) return Math.min(Math.max(0, at - Date.now()), 60_000); - } - const base = 2 ** (attempt - 1) * 500; - return base + Math.random() * base; // full jitter -} - -function delay(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - const t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true }); - }); -} - -const client = new {Api}Client({ fetch: retryingFetch(3) }); -``` - -Five things that make or break it: - -- **Never retry a caller abort or a timeout.** Checking `init.signal.aborted` before retrying is what - separates "the server is struggling" from "we were told to stop." Without it, an aborted request - keeps retrying after the caller has given up, and the client `timeout` — which aborts through that - same signal — silently becomes a per-attempt budget you multiply. -- **Only retry idempotent methods** unless the API documents a safe replay. A retried `POST` can - double-charge. See *Making a write safe under retries*. -- **Respect `Retry-After`** in both its numeric-seconds and HTTP-date forms, and **cap it**. A provider - asking for a ten-minute pause will otherwise hold the request open for ten minutes. -- **Jitter the backoff.** Without it, every client in a fleet retries in lockstep. -- **Decide what the token request should do.** The wrapper sees it too. It is a `POST`, so the method - gate above already excludes it — which is usually right, but means a transient `503` from the token - endpoint fails the operation outright. - -**Do the arithmetic before you accept the shape.** The client `timeout` bounds **each attempt**, not -the total, so three attempts at the default `60_000` plus backoff is a worst case near **three -minutes** — on a wrapper whose configuration mentions no such number anywhere. Nothing in the SDK -imposes a wall-clock ceiling; the next section is how you impose one. - ### Making a write safe under retries -**Start from what you have.** The SDK resends nothing on its own, so an unwrapped client already holds -every write at one send. The exposure arrives with your wrapper, and with two things it cannot fix: +**Start from what you have.** With the default gate a `POST`, `PATCH` or `DELETE` is sent once. The +exposure arrives when you widen `httpMethodsToRetry` or add a retry layer of your own, and with two +things no setting fixes: -- **`PUT` is idempotent by HTTP's definition, not necessarily by your provider's.** A `PUT` can still - carry a per-call side effect — an audit row, an outbound webhook, a metered charge — that a resend - duplicates. +- **`PUT` is in the default gate — idempotent by HTTP's definition, not necessarily by your + provider's.** A `PUT` can still carry a per-call side effect — an audit row, an outbound webhook, a + metered charge — that a resend duplicates. Where yours does, drop `"PUT"` from + `httpMethodsToRetry`. - **"It was not resent" is not "the write did not happen."** A transport failure on a `POST` leaves the outcome *unknown*: the bytes may have reached the provider before the socket died. That is a reconciliation problem, and no retry setting solves it. @@ -363,49 +393,51 @@ every write at one send. The exposure arrives with your wrapper, and with two th regardless of how the client is later configured. The four, **weakest guarantee first** — so do not read the numbering as a recommendation order: -1. **Make the write idempotent at the provider** — a client-supplied unique reference or idempotency - key, where the API offers one. Makes a resend *harmless* rather than rarer; the send count stays - above one. +1. **Make the write idempotent at the provider.** Makes a resend *harmless* rather than rarer; the send + count stays above one. - Look at the operation's **whole Fields table**, not just its `body`. The request object is flat and - channel-blind, so a key can arrive as a header, a query parameter, a form field or a body member and - they all look alike in the signature — the `Channel` column is what tells them apart. See - **typescript-calling-endpoints**. + **The SDK sends an `Idempotency-Key` header on every operation other than a `GET`** — a UUID minted + once per call, unless the operation declares a header of that name itself, in which case that one is + a field you set. **Every attempt of the SDK's retry loop re-sends the same key**, because the loop + re-sends the request the SDK already built. That is what makes naming a write in + `httpMethodsToRetry` safe — **where the provider deduplicates on it**. A retry of your own is a new + call and mints a new key: the provider sees two submissions. - **Generate the key once per logical action and reuse it across every attempt** — that is the whole - mechanism, and it is the easy thing to get backwards: + Where the operation declares its own key — a header, a query parameter, a form field or a body + member, all alike in the flat, channel-blind request object; the `Channel` column of its **Fields** + table tells them apart (**typescript-calling-endpoints**) — **generate it once per logical action and + reuse it across every attempt you make**: ```ts const requestId = crypto.randomUUID(); // once per logical action, NOT per attempt - await withRetry(() => client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId })); + await withRetry(() => + client.{resource}.{operation}({ ...body, {idempotencyKey}: requestId }, { retry: { maxRetries: 0 } }), + ); ``` A key generated inside the retried function is a fresh key on every attempt, which deduplicates - nothing while looking exactly like a solution. Note this only works at the **caller-level** seam: a - transport-level wrapper resends bytes the SDK already encoded, so the key is stable there for free. + nothing while looking exactly like a solution. - Three cautions, none of them visible in the type: + Two cautions, neither of them visible in the type: - - **Nothing is injected.** Unlike some generators, this one adds no `Idempotency-Key` header of its - own, so a key on the wire is one a spec parameter declared and one you set. - - **Whether the provider actually enforces it is not visible in the model.** Such a field is - typically just an optional string, equally consistent with an enforced key and with a free-text - label. Verify against live traffic before relying on it. + - **Whether the provider actually enforces it is not visible in the SDK.** The header being sent, + or a declared key field existing, is not a guarantee that a resend is deduplicated. Verify + against the provider's documentation or live traffic before widening the gate. - **Keys expire, and the retention window differs per API.** A resend after a long backoff may fall outside it and be treated as a new request. Only the provider's documentation carries that number. 2. **Reconcile after a failure** — on a transport failure on a write, re-read provider state to establish what actually happened instead of assuming nothing did. Detects a duplicate; does not prevent one. Same reflex as an unreadable write response — see **typescript-error-handling**. -3. **A separate client for writes**, constructed without the retry wrapper. The wrapper is a - constructor argument, so this is one extra client and no other change: +3. **Send the write once, explicitly** — `{ retry: { maxRetries: 0 } }` on that call, or a separate + client for writes built with `retry: { maxRetries: 0 }`: ```ts - const reads = new {Api}Client({ ...shared, fetch: retryingFetch(3) }); - const writes = new {Api}Client({ ...shared }); // no resends, ever + const reads = new {Api}Client({ ...shared }); + const writes = new {Api}Client({ ...shared, retry: { maxRetries: 0 } }); // no resends ``` - Holds at one send today; stops holding the moment someone adds a wrapper to `writes`, and that - failure does not announce itself. + Holds at one send today; stops holding the moment someone removes the override or widens the gate + on `writes`, and that failure does not announce itself. 4. **A guard wrapper that refuses a re-send it did not authorise** — the only option that holds the count at one no matter how the client is configured, because a blocked attempt never reaches the network. Reach for it whenever a duplicate would be externally visible or costly to undo, and @@ -413,27 +445,27 @@ read the numbering as a recommendation order: Three details decide whether it works: - - **The guard must be the *innermost* wrapper.** `retryingFetch(guardFetch(fetch))` puts every - attempt through the guard; `guardFetch(retryingFetch(fetch))` puts only the first one through it, - and the retry loop resends freely underneath. Composition order *is* the mechanism here. + - **A `fetch` wrapper sees every attempt of the SDK's loop by construction**, because the loop sits + above `fetch`. But its refusal reaches the loop as a `ConnectionError`, which **is retried** for a + method in the gate — each retry is refused again, and the call rejects only after the backoff, + with your error on `.cause`. Pair the guard with option 3 on the guarded write, so the refusal is + immediate, and recognise it with `err.cause instanceof YourRefusal`. - **Keep the "already sent" marker in state that outlives one attempt** — an `AsyncLocalStorage` scope the caller opens around the write, or a closure created per unit of work. A marker hung off `init` is per-attempt bookkeeping only, and a caller-level retry (a job re-running after a timeout) creates a fresh one either way. - - **Do not let the refusal look retryable.** Throw a private sentinel class and check for it in the - retry wrapper's `catch` before deciding to retry, exactly as you check `init.signal.aborted`. - Remember the guard also sees the OAuth token request; key it on method and URL so a token - acquisition is not counted as the write. + - **Key it on method and URL.** The guard also sees the OAuth token request, and a token + acquisition must not be counted as the write. Count the send **before** it goes out. A request that failed on the way out may still have been received, so "this may already have taken effect" is the only safe reading — surface it as an **unknown outcome** to be settled by re-reading provider state (option 2), not as a definite failure. -**Where an operation offers no idempotency parameter at all, retrying it is a real duplication risk** -and the safe recovery is option 2 — re-read state and decide — not a resend. Some operations are -naturally idempotent anyway (cancelling an already-cancelled resource is usually harmless), but that is -a **per-operation judgement** made against the provider's documentation, not a property you can assume -for the whole class of writes. +**Where the provider ignores the key, retrying a write is a real duplication risk.** Keep the write +out of the gate; the safe recovery is option 2 — re-read state and decide — not a resend. Some +operations are naturally idempotent anyway (cancelling an already-cancelled resource is usually +harmless), but that is a **per-operation judgement** made against the provider's documentation, not a +property you can assume for the whole class of writes. **All four bound the call YOU make to the provider. None of them is a reason to change the contract your own callers see.** Key the guard on something you already hold — a reference derived @@ -446,28 +478,32 @@ permanent refusal. Clear it once the outcome is settled, and expire it when it n | Layer | Scope | Default | Bounds a whole call? | | --- | --- | --- | --- | -| `ClientOptions.timeout` | one attempt through `fetch` | `60_000` ms | **No** — a retry wrapper multiplies it | -| the `signal` you pass to the call | everything from dispatch to resolution | none | **Yes** — the only one | - -### `timeout` - -In **milliseconds**, bounding **one request attempt** — enforced with an `AbortController` inside the -transport, surfacing as a Family B rejection with `err.kind === "timeout"`. What the field list does -not tell you: - -- **It covers credential acquisition too.** The timer starts before the auth scheme resolves, and an - OAuth token fetch happens inside it. So on the first call of a cold client, `timeout` is the budget - for the token round-trip **plus** the operation, not for the operation alone. -- **A non-finite or non-positive value is not "no timeout".** The transport falls back to its own - ceiling of `100_000` ms, and clamps anything above `2_147_483_647` ms — the largest value a timer can - hold. `timeout: 0` and `timeout: Infinity` both mean 100 seconds. -- **There is no per-request timeout.** One value covers every call on that client. If different - operations need different budgets, build **two clients**, or impose the tighter budget with a signal. -- **A `fetch` replacement that drops `init.signal` makes it inert.** Always spread `...init`. +| `retry.timeout` | one attempt, up to its response headers | `60_000` ms | **No** — an idempotent call gets up to `maxRetries + 1` attempts, plus the waits between them | +| the `signal` you pass to the call | everything from dispatch to resolution, retries and waits included | none | **Yes** — the only one | + +### `retry.timeout` + +In **milliseconds**, bounding **one attempt** — enforced with an `AbortController` inside the +transport, surfacing as a `TimeoutError` (`err.kind === "timeout"`) once no retry is left. Each attempt +gets its own deadline. What the field list does not tell you: + +- **It stops at the response headers.** The timer starts once the credential is in hand, so obtaining + a credential and reading the body are bounded only by your `signal`. A built-in OAuth token request + runs on its own timer. +- **A value the platform cannot honour is not "no timeout".** Negative, fractional, `NaN`, `Infinity` + and anything above `2_147_483_647` ms — the largest value a timer can hold — all fall back to the + SDK's default (on a call, to the client's value), silently, and the client still constructs. A usable + value is installed **verbatim**, `0` included, so `timeout: 0` is a zero-length deadline that fails + every attempt rather than lifting it. An unbounded attempt is not expressible. +- **One call can take its own.** `{ retry: { timeout: 5_000 } }` on the call replaces the client's + value for that call — still per attempt. For a budget on the whole call, use a signal. +- **A `fetch` replacement that does not forward the `Request`'s `signal` makes it inert.** Pass the + `Request` on, or copy it with `new Request(request)`, which keeps the signal. ### Cancellation and real deadlines -`RequestOptions` is `{ signal?: AbortSignal }` and that is the whole per-call surface. +`RequestOptions` is `{ signal?: AbortSignal; retry?: RequestRetryOptions; logging?: RequestLoggingOptions }`, +and `signal` is the whole per-call cancellation surface. ```ts const controller = new AbortController(); @@ -479,10 +515,12 @@ try { } ``` -A caller abort surfaces as `err.kind === "abort"`, and `err.cause` is whatever you passed to `abort()` — -so pass something diagnostic. An already-aborted signal rejects immediately. +A caller abort rejects with whatever you passed to `abort()` — the signal's own `reason`, unwrapped and +outside the `{Api}Error` family — so pass something diagnostic, and rethrow it from a family `catch`. +An already-aborted signal rejects immediately, and an abort during a backoff wait ends the wait and the +call; an abort is never retried. -For a **deadline across a whole unit of work** — several calls, or a call plus a retry loop — +For a **deadline across a whole call or unit of work** — one call and its retries, or several calls — `AbortSignal.timeout` and `AbortSignal.any` compose (both on Node 20+): ```ts @@ -492,14 +530,16 @@ async function withDeadline(work: (signal: AbortSignal) => Promise, ms: nu } ``` -That is also how you give one operation a tighter budget than the client-wide `timeout`: the earlier of -the two wins. +That is how you give one operation a ceiling that `retry.timeout` cannot: the deadline covers every +attempt and every wait, and the earlier of the two wins. ⚠▶▶ **A per-attempt timeout does not bound a request.** If one handler makes more than one SDK call — a loop over records, a fan-out, a create-then-confirm pair — the per-call timeouts **add up**, and they add up *faster* when each failure is caught so the work can continue: every swallowed timeout costs its -full bound and the next call still runs. **The check is arithmetic, not judgement:** `calls in this -handler × timeout` must sit under the budget you are willing to make a caller wait. Count the calls in +full bound and the next call still runs — and an idempotent call spends up to `maxRetries + 1` of +them before it fails. **The check is arithmetic, not judgement:** `calls in this handler × +(maxRetries + 1) × timeout`, plus the waits, must sit under the budget you are willing to make a caller +wait. Count the calls in the loop, not the calls in the snippet — a handler that processes every record on file makes as many calls as there are records. @@ -616,87 +656,163 @@ async function* all{Items}(signal?: AbortSignal): AsyncGenerator<{Item}> { A failed page rejects mid-enumeration like any other call — see **typescript-error-handling**. -## Streaming and binary responses — not carried +## Event streams — not carried -There is no SSE support, no `ReadableStream` handling, and no binary response type. This is not simply -an omission you can work around at the call site: when the spec declares a `text/event-stream` or -binary success response, the generator **still emits the operation** but gives it the **empty-body -decoder**, because the vendored runtime has no carrier for either. +There is no SSE support. When the spec declares a `text/event-stream` success response, the generator +**still emits the operation** but gives it the **empty-body decoder**, because the vendored runtime +has no carrier for it. A **binary** success is different and is carried: it resolves to a +`BinaryContent` whose `stream` you read once or release — see **typescript-calling-endpoints**. -The consequence is specific and worth knowing before you debug it: the empty decoder asserts the body -is empty, so such an operation **fails at the call** with `SchemaError` ("Expected an empty response -body") the moment the server sends anything — rather than quietly resolving something the spec never -described. The operation's **Returns** bullet reads `undefined` for these. +The consequence of the empty decoder is specific and worth knowing before you debug it: it asserts +the body is empty, so such an operation **fails at the call** with `DecodeError` ("Expected an empty +response body") the moment the server sends anything — rather than quietly resolving something the +spec never described. The operation's **Returns** bullet reads `undefined` for these. -Same story outbound: multipart, binary and XML request bodies have no carrier, so those operations are -emitted with no body field at all. See **typescript-calling-endpoints**. +Outbound, only an XML request body has no carrier, so such an operation is emitted with no body field +at all. Multipart and binary bodies are both sent. See **typescript-calling-endpoints**. **If you need one of these endpoints, call it with `fetch` directly** — build the URL from the same base URL, and copy the credential header the SDK would have sent. The SDK cannot carry it, and no option makes it. -## Logging — none built in +## Logging -`src/core/` contains no `console` call, so an integration is silent until you make it otherwise. The -wrapper: +**The SDK has a built-in request/response logger, and it is off by default.** Turn it on through +`ClientOptions.logging`, a `LoggingOptions` exported from the package root with `Logger`, `LogLevel`, +`LogDetails` and the value `defaultLoggingOptions`: ```ts -function loggingFetch(inner: typeof fetch = fetch): typeof fetch { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const started = performance.now(); - log.debug({ method: init?.method ?? "GET", url: redact(url) }, "--> request"); +import { {Api}Client, defaultLoggingOptions } from "{package-name}"; - try { - const response = await inner(input, { ...init }); - log.debug({ status: response.status, ms: Math.round(performance.now() - started) }, "<-- response"); - return response; - } catch (err) { - log.warn({ err, ms: Math.round(performance.now() - started) }, "<-- failed"); - throw err; - } - }; -} +const client = new {Api}Client({ + logging: { + logger: appLogger, // any { error, warn, info, debug }; default `console` + level: "info", // "off" | "error" | "warn" | "info" | "debug" + logRequestHeaders: false, // default + logResponseHeaders: false, // default + logRequestBody: false, // default — read the warning below before enabling + redactedKeys: [...defaultLoggingOptions.redactedKeys, "card_number"], + }, +}); ``` -⚠▶▶ **Nothing here is redacted for you, and every channel can carry a credential.** There is no -allow-list, no `RedactedKeys`, no placeholder — a hand-rolled logger prints exactly what you hand it: +How it turns on — the first row that applies wins: -- **The URL.** An api-key scheme may put the key in the **query string**, so a logged URL can leak it - outright. The path also carries ids and references. Write `redact()` and use it — allow-list the - query keys you want to see rather than deny-listing the ones you do not. -- **The headers.** `Authorization` is on `init.headers`, fully formed. Never log the header bag whole. -- **The request body.** On a form-urlencoded operation that is where the fields live, and on the OAuth - token request it is where `client_secret` lives. The wrapper sees the token request like any other. -- **The response body.** ⚠ **Reading `response.body` consumes it and the SDK then decodes nothing.** - Clone first (`response.clone().text()`), and know that doubles memory for large bodies. +| what is set | what is logged | +| --- | --- | +| `logging.logger` | everything the switches allow, at `debug` unless `level` says otherwise — your logger's own level does the filtering | +| `logging.level`, and no `logger` | lines at that level and above, to `console` | +| neither, and `{APICLIENTTYPENAME}_LOG` holds a level | see the warning below | +| nothing | nothing — `level` is `"off"` | -Turn body logging on for one endpoint at a time, behind a flag you can prove is off in production. +What it writes, and at which level. The SDK filters by level itself, then calls your logger's method of +the same name: + +| Line | `event` | Level | Written when | +| --- | --- | --- | --- | +| `sending {METHOD} {url}` | `HttpRequest` | `info` | every attempt | +| `request headers` | `RequestHeaders` | `debug` | `logRequestHeaders` | +| `request body` | `RequestBody` | `debug` | `logRequestBody`, and the media type is in `loggableContentTypes` | +| `{METHOD} {url} succeeded with status {n} in {ms}ms` | `HttpResponse` | `info` | a `2xx` | +| `{METHOD} {url} failed with status {n} in {ms}ms` | `HttpResponse` | `warn` | any other status | +| `response headers` | `ResponseHeaders` | `debug` | `logResponseHeaders` | +| `{METHOD} {url} retrying in {ms}ms (retry {n} of {max}) after {reason}` | `HttpRetrying` | `warn` | before each retry, ahead of `onRetry` | +| `{METHOD} {url} failed after {ms}ms: {ErrorName}` | `HttpFailed` | `error` | a call that ends on a fault or on a credential it could not obtain | + +Each line is `logger.{level}(message, details)`: the message is complete text, and `details` is a +`LogDetails`, a union you narrow on `event`. The SDK calls the method **on** your logger, so one that +reads its own state through `this` works as it is. `console` and winston take `(message, meta)` and +plug in directly; pino takes `(details, message)` and needs a four-line adapter: + +```ts +import pino from "pino"; +import type { Logger } from "{package-name}"; + +const p = pino(); +const appLogger: Logger = { + error: (message, details) => p.error(details, message), + warn: (message, details) => p.warn(details, message), + info: (message, details) => p.info(details, message), + debug: (message, details) => p.debug(details, message), +}; +``` -This same wrapper is where **OpenTelemetry spans, metrics and request-id propagation** belong — it is -the one place that sees every outbound request, its status and its duration. +**Keep the logger non-throwing.** An error thrown from it ends the call with that error — on a +response line, after the request already reached the server. + +**The URL and the headers are redacted by allow-list, which is the safe direction.** A query value is +masked unless its key is on a short known-safe list (`cursor`, `dates`, `datetime`, `limit`, +`offset`, `page`, `since`, `size`, `strings`); a header is masked unless it is an ordinary transport +header — `authorization`, `cookie` and every api-key header are not on that list, so they are masked. +`redactedKeys` (default: `sig`, `signature`, `access_token`, `apikey`, `api_key`, `client_secret`, +`password`, `refresh_token`, `code`, `assertion`, `client_assertion`) adds keys to the masked set, +`redactedHeaders` forces a header in, and `unmaskHeaders` opts one out; the mask is +`redactionPlaceholder` (`"***"`). The URL **path** is not masked — an id or reference in the route is +logged as it is. `redactedKeys` and `loggableContentTypes` **replace** their default rather than add to +it, so spread `defaultLoggingOptions` to extend one, as above. + +⚠▶▶ **`logRequestBody: true` does not redact a JSON body**, and only half-redacts a form body. A JSON +body is logged **verbatim** — truncated at `bodySizeLimit` bytes (32 KB), and only when its media type +is in `loggableContentTypes` (`application/json`, `application/x-www-form-urlencoded`). A form body gets +key masking, but by **deny-list**, not by the allow-list the URL uses: only keys in `redactedKeys` are +masked, so `card_number` in a form body prints in the clear although the same key in a query string +would not. Any other media type is not logged unless you add it to `loggableContentTypes`. On any +endpoint that carries card data, bank details, personal data or a credential in the body, switching +this on writes that data to your logs. Leave it off in production; turn it on for one endpoint at a time +behind a flag you can prove is off. + +⚠▶▶ **An environment variable turns logging on without a code change.** When code sets neither a +`logger` nor a valid `level`, the client reads `{APICLIENTTYPENAME}_LOG` once, as it is built, through +`process.env` — a runtime without `process` reads nothing. It takes a level name (`error`, `warn`, +`info`, `debug`, or `off`) and writes to `console`. `debug` also turns **both header switches** on; the +request body it never turns on — only code can. A value that is not a level warns once on `console`, +and logging stays off. Setting `logger` or `level` in code disables the variable outright — which is the +reason to set `logging: { level: "off" }` explicitly where you must guarantee silence. + +**Per-call override.** `RequestOptions.logging`, typed `RequestLoggingOptions`, takes one field, `level`, +for that call alone. Two things about it are easy to get wrong: + +- It **replaces** the client's settings for that call rather than raising them. A per-call `level` + decides every line by itself with **all three switches on**, so `{ logging: { level: "debug" } }` + writes both header lines **and the request body**, whatever the client set. `{ logging: { level: + "off" } }` silences that one call. +- It cannot lift your logger's own filter. With a `logger` supplied, a per-call `debug` hands it `debug` + lines, and a logger filtering at `info` drops them; `console` has no filter of its own. + +A per-call value that is not a level is ignored, and the call logs under the client's settings. + +**Resolution.** A field whose value the SDK cannot honour falls back to its default, silently, and the +client still constructs. A `logging` record whose read throws — a throwing getter, a revoked proxy — is +the one failure: a `ConfigurationError` from the constructor, or the rejection of the one call that +passed it. + +**OpenTelemetry spans, metrics and request-id propagation are not logging** — they belong on the +`fetch` seam, the one place that sees every outbound request, its status and its duration. ### Verify on the wire (first run of a new integration) On a **successful** call the SDK returns only the decoded body — it never surfaces the request URL, the verb or the status. So a wrong verb, a mis-serialized path segment or a dropped query parameter -**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Wrap -`fetch` with the logger above on the first execution of any new call and check five things: +**compiles cleanly** and produces no in-band signal; the only symptom is a runtime `404` or `422`. Turn +logging on at `info` for the first execution of any new call — `{APICLIENTTYPENAME}_LOG=info`, or +`logging: { level: "info" }` — and check five things in its `sending` line: 1. The **verb** matches the operation's **Wire** bullet in `map/operations/{resource}.md`. 2. The **base URL** is fully expanded — no leftover `{region}`-style server variable, which means a `baseUrl` you supplied carries a placeholder no declared variable fills. 3. The **path** is fully formed. Two different failures live here: a path field set to `null` collapses - to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` leaves - the marker unfilled and raises `SdkError` naming it. The first is silent, so it is the one to look + to **nothing**, leaving a doubled slash (`/orders//confirm`); a path field left `undefined` is + rejected by its schema before anything is sent, as an `EncodeError` whose `uri` still shows the + unfilled marker. The first is silent, so it is the one to look for. 4. Each **path segment** holds the value you meant — for an enum that is the **wire value**, not the member name. 5. The **query parameters** you set are present, spelled as their `Wire` names. A `null` or `undefined` query value is dropped entirely rather than sent empty, so an absent parameter usually means an - absent field, not an encoding bug. + absent field, not an encoding bug. The line masks a value whose key is off the safe list, so it + shows that a parameter was sent, not what it held. -Then remove the wrapper, or gate it behind a log level. +Then turn the level back down. ## Other work on the `fetch` seam @@ -705,15 +821,17 @@ Then remove the wrapper, or gate it behind a log level. ```ts const client = new {Api}Client({ fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-correlation-id", currentCorrelationId()); - return fetch(input, { ...init, headers }); + const request = new Request(input, init); + request.headers.set("x-correlation-id", currentCorrelationId()); + return fetch(request); }, }); ``` -Build from `new Headers(init?.headers)` rather than replacing the object — the SDK has already put the -content type and the credential there. This is also the only client-wide header mechanism: the +Set the header on a copy of the `Request` — the SDK has already put the content type and the +credential on it, and the copy keeps them, the body and the signal. Passing `{ headers }` as `init` +instead **replaces** every header the SDK set, the credential included. This is also the only +client-wide header mechanism: the transport carries default header, query and path-parameter channels, but the generated client currently wires all three as empty arrays. If a later generator version starts populating them, a header option could appear on `ClientOptions` — read `src/client-options.ts` rather than assuming the field list is @@ -721,15 +839,19 @@ frozen. ### Rate limiting -There is no built-in limiter. Either handle `429` in the retry wrapper (above, via `Retry-After`), or -put a concurrency/rate limiter around your calls. Client-side limiting is the more predictable of the -two under load — retrying into a rate limit converts a fast failure into a slow one. +There is no built-in limiter. The default policy retries a `429` on an idempotent call and honours +its `Retry-After` (capped at 60 s), which absorbs an occasional limit; it does nothing to stop you +starting more calls than the provider allows. Put a concurrency/rate limiter around your calls for +that. Client-side limiting is the more predictable of the two under load — retrying into a rate limit +converts a fast failure into a slow one, and a fan-out of retrying calls keeps the pressure on. ### Circuit breaking -Also a `fetch` wrapper: count consecutive transport failures and 5xx responses, open the circuit for a -cool-off window, and fail fast while it is open. Throw a plain `Error` from the wrapper and it reaches -the caller as `ConnectionError` with your error on `.cause`. +Count consecutive `ConnectionError`s, `TimeoutError`s and 5xx `ApiError`s, open the circuit for a +cool-off window, and fail fast while it is open. **Put the breaker around the SDK call, not in `fetch`.** +A plain `Error` thrown from a `fetch` wrapper reaches the loop as a `ConnectionError`, which is retried +with backoff — the opposite of failing fast. Around the call, the breaker sees one outcome per call, +after the retries, and its refusal never enters the loop. ## Proxies and TLS (Node) @@ -741,7 +863,7 @@ import { ProxyAgent } from "undici"; const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!); const client = new {Api}Client({ - fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit), + fetch: (request) => fetch(request, { dispatcher } as RequestInit), }); ``` @@ -805,21 +927,26 @@ client costs under OAuth — is **typescript-client-initialization**. | Symptom | Where to look | | --- | --- | -| calls never time out | a `fetch` replacement dropping `init.signal` | -| a retry loop keeps going after a cancel | the retry wrapper not checking `init.signal.aborted` | -| one handler takes far longer than `timeout` | `timeout` is per attempt — count the calls, then bound the handler with a signal | +| calls never time out | a `fetch` replacement that does not forward the `Request`, or its `signal` | +| a header added in a `fetch` wrapper arrives, but every call is now a `401` | the wrapper passed `{ headers }` as `init`, which replaces the SDK's headers — copy the `Request` instead (*Header injection*) | +| request lines in the logs with no logging code | `{APICLIENTTYPENAME}_LOG` is set in that environment — set `logging.level` in code to own it | +| a JSON body or a card number in the logs | `logRequestBody`, or a per-call `level`, which turns the body on — see *Logging* | +| a failing call takes seconds longer to reject than the first failure | expected on an idempotent call: the default policy retried it — watch it with `retry.onRetry` | +| a `POST` fails on a `503` that a `GET` survives | expected: `POST` is outside the default `httpMethodsToRetry` | +| one handler takes far longer than `retry.timeout` | it is per attempt, and an idempotent call gets up to four — count the calls, then bound the handler with a signal | | a page loop never returns | no provider-independent bound — see *Never leave a page loop unbounded* | | requests still hit the real host under test | the wrong server **group** or **environment** overridden — check `src/servers.ts` | | auth fails everywhere with credentials you know are right | `serverEnvironment` left to its default, which is the spec's **first** environment — often sandbox | | the call works locally, fails behind a corporate proxy | Node's `fetch` ignores `HTTP_PROXY` — register `EnvHttpProxyAgent` or pass a `dispatcher` | -| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out | -| ~9× the expected round-trips | a transport-level and a caller-level retry stacked | +| a duplicate write despite an idempotency key | the key is generated inside the retried function — hoist it out; or your own retry layer re-calls the SDK, which mints a new injected key per call | +| ~16× the expected round-trips | a retry layer of your own stacked on the SDK's — set `retry: { maxRetries: 0 }` where you own retrying | +| a test with one stubbed `503` hangs, or runs out of queued responses | the default policy fetched it four times across ~7 s of backoff — see **typescript-testing** | | a `{placeholder}` survives into the URL | a `baseUrl` you supplied names a variable the group does not declare | -| `SdkError` naming an unfilled path parameter | a path field was `undefined` — a required field was omitted | -| `SdkError` about an unknown environment, thrown synchronously | `serverEnvironment` is not a declared member; `.asApiResult()` cannot see it | -| `SchemaError` "expected an empty response body" | a streaming or binary response with no carrier — call it with `fetch` directly | +| `EncodeError` whose `uri` still shows a `{placeholder}` | a path field was `undefined` — a required field was omitted | +| `ConfigurationError` about an unknown environment, thrown by the constructor | `serverEnvironment` is not a declared member | +| `DecodeError` "Expected an empty response body" | an event-stream response with no carrier — call it with `fetch` directly | | a token is fetched on every call | a client built per request — see **typescript-client-initialization** | -| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and does **not** retry | +| 401 on the first call, fine afterwards | expected: a 401 invalidates the token cache and is not in the default retry set | ## Next diff --git a/plugins/alpaca/skills/typescript/typescript-error-handling/SKILL.md b/plugins/alpaca/skills/typescript/typescript-error-handling/SKILL.md index 1b3ba2dde..db15dba17 100644 --- a/plugins/alpaca/skills/typescript/typescript-error-handling/SKILL.md +++ b/plugins/alpaca/skills/typescript/typescript-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-error-handling -description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the two disjoint error families and which types actually reach your catch blocks, reading the status and the declared error body safely, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. +description: Error handling for an APIMatic-generated TypeScript SDK — load before writing any try/catch around an SDK call, an error-translation layer, or middleware. Covers the one error family and its closed err.kind, the four throwables that sit outside it, which types actually reach your catch blocks, reading the status and the declared error body safely, the default arm that degrades instead of throwing, the failures the non-throwing form does not convert, the missing field that is silently undefined rather than an error, and the traps that make an otherwise reasonable catch ladder silently wrong. --- # Error handling for an APIMatic TypeScript SDK @@ -16,32 +16,43 @@ description: Error handling for an APIMatic-generated TypeScript SDK — load be > matches**. An API can declare typed errors on every operation, on none, or on a mix. Operations are **throw-based** (for a non-throwing alternative, see **The non-throwing form** below), -and failures fall into **two disjoint families**. Neither is `instanceof` the other, so the two -branches can never overlap and a complete `catch` needs **both**. - -- **Family A — the API answered with an error status.** The call rejects with `ResponseError`, or with - a generated subclass of it where the spec declared error bodies for that operation. Anything outside - `200`–`299` is a failure, so a `3xx` lands here too. -- **Family B — no usable response was produced.** The call rejects with a member of the `{Api}Error` - set. `{Api}Error` is the SDK-branded alias of the runtime's `CoreError` and is **abstract** — use it - for `instanceof`, never construct it. +and every **operational** failure belongs to **one family**: `{Api}Error`, a union over six leaves. +One `instanceof {Api}Error` sees all of them, and `err.kind` says which one it is. Four throwables sit +**outside** the family — a configuration fault, a codec called directly, a bug, and a caller abort — +so a `catch` that tests the family has to rethrow whatever is left. ```ts -import { ResponseError, {Api}Error } from "{package-name}"; +import { {Api}Error } from "{package-name}"; try { await client.{resource}.{operation}({ /* ... */ }); } catch (err) { - if (err instanceof ResponseError) { - // The API answered with an error status — read err.status and err.payload. - } else if (err instanceof {Api}Error) { - // No usable response — err.kind says which. + if (err instanceof {Api}Error) { + switch (err.kind) { + case "api": + // The API answered with an error status — read err.status and err.payload. + break; + case "decode": + // The answer did not fit the spec — read err.status and err.cause. + break; + case "encode": + // Nothing was sent — err.cause is the SchemaError that rejected the value. + break; + case "connection": + case "timeout": + case "auth": + // No response was produced — err.kind says which. + break; + } } else { - throw err; // not from the SDK + throw err; // outside the family: an abort's reason, a configuration fault, or a bug } } ``` +Every member of the family names the call it raised: `err.method` and `err.uri`, and `err.message` +opens with that pair — `"POST https://api.example.com/v1/{resource} failed with 422"`. + ## What to import, and from where Everything below comes from **one specifier — the package root**. Deep imports @@ -50,44 +61,94 @@ nothing else. | import | what it is | | --- | --- | -| `ResponseError` | Family A base — the type every error status rejects with | -| `{Resource}.{Operation}Error` | the generated Family A subclass, on the **resource class's merged namespace** — not a free-standing export | -| `{Api}Error` | Family B base, **abstract**. The SDK-branded alias of `CoreError`, which is not exported under its own name | -| `ConnectionError` `TimeoutError` `AbortError` `SdkError` `SchemaError` `AuthError` | the six concrete Family B classes | -| `type ErrorKind` `ErrorPayload` `Declared` | type-only helpers, if you name them in your own signatures | +| `{Api}Error` | the whole family — a type **and** a value. The type is the union of the six leaves; the value is the abstract class they all extend, so `instanceof` and `err.kind` select the same set. It cannot be constructed or extended. The SDK-branded alias of `CoreError`, which is not exported under its own name | +| `ResponseError` | the part of the family the server answered on — `ApiError \| DecodeError`, the two that carry `status` and `headers`. A type and a value, like `{Api}Error` | +| `ApiError` | `kind: "api"` — the API answered with an error status. The base every operation without declared error bodies rejects with, and the class every per-operation error extends | +| `{Resource}.{Operation}Error` | the generated `ApiError` subclass for an operation that declares error bodies, on the **resource class's merged namespace** — not a free-standing export | +| `DecodeError` `EncodeError` `ConnectionError` `TimeoutError` `AuthError` | the other five leaves | +| `ConfigurationError` `SchemaError` | outside the family: a constructor fault, and a codec called directly | +| `type ErrorKind` `ErrorPayload` `Declared` `Undeclared` `HttpMethod` | type-only helpers, if you name them in your own signatures | `{Api}Error` is the SDK-branded name — **read the real one** from the `CoreError as …Error` line in `src/index.ts`. It is the API name plus `Error` (`Acme` gives `AcmeError`), escalating to `{Api}ApiError` and then a numeric suffix when that would collide with something the same clause -already re-exports (an API named `Sdk` cannot take `SdkError`). +already re-exports (an API named `Api` cannot take `ApiError`). Everything under `src/core/` is vendored static code, **byte-identical in every generated TypeScript SDK**, so the behaviours below hold without checking yours. What varies per SDK is which operations declare arms, and what those arms are called. -## Family A — the API answered +## The family — every `err.kind` + +`ErrorKind` is closed, so a `switch` over `err.kind` is exhaustive: + +| `err.kind` | Class | What happened | Adds | +| --- | --- | --- | --- | +| `"api"` | `ApiError` | the API answered with an error status | `status` · `headers` · `payload` | +| `"decode"` | `DecodeError` | the answer could not be turned into the declared value — the body was not JSON, failed its schema, arrived where none is declared, or died mid-read after the response line | `status` · `headers` | +| `"encode"` | `EncodeError` | a request value did not match its declared type, so **nothing was sent** | — | +| `"connection"` | `ConnectionError` | `fetch` rejected before a response line arrived | — | +| `"timeout"` | `TimeoutError` | an attempt ran out its `retry.timeout`, and no retry was left | `timeout` — the budget that ran out | +| `"auth"` | `AuthError` | a credential could not be **obtained** | — | + +Every one carries `kind`, `method`, `uri`, `message` and, where there was an underlying failure, +`cause`. `uri` is the absolute URL the call dialled, with the server variables expanded and the path +parameters filled. It carries no query, fragment or userinfo, so no query parameter reaches it — a +credential sent in the query included. One failure names an unresolved URI: a path parameter rejected +by its schema arrives as an `EncodeError` whose `uri` still shows the unfilled `{braces}`. + +⚠ **`err.message` never carries a value from the wire.** It is the call plus what went wrong — +`"GET https://… failed with 404"`, `"… failed: Response body could not be decoded."` — never a body, +a header or a value that failed its schema. That keeps response bodies and secrets out of logs and +stack traces by default, which is a sound default and a trap: a handler that logs `err.message` and +nothing else records that something failed and discards every diagnostic. Read `err.payload` (or +`err.cause`) explicitly and log the fields you choose — and on a `4xx` the caller caused, also put the +API's reason in your response. + +`err.headers` is the `fetch` `Headers` object, not a plain object — look a header up with +`err.headers.get("x-request-id")` (case-insensitive). Indexing it (`err.headers["x-request-id"]`) is +always `undefined`, and spreading it gives you nothing. + +## Four throwables outside the family + +`instanceof {Api}Error` is `false` on each of these, so a `catch` that tests the family and then +stops has swallowed nothing — it just has to **rethrow the rest**, as the ladder at the top does. + +- **`ConfigurationError`** comes out of the **client constructor**, synchronously and before any + call exists: no reachable `fetch`, a basic-auth username containing a colon (RFC 7617 §2), or an + unknown `serverEnvironment`. It is not a call failure, so it names no call. Wrap construction too + if it runs on a path that must not crash. +- **`SchemaError`** is what a model's schema value throws when **you** call it directly — + `{model}Schema.decode(json)`. Through an operation the same failure arrives one level down, as the + `cause` of a `DecodeError` or `EncodeError` that does name the call. Its `message` names the field + and the type expected, never the value; `cause` carries the issue list. A `serverOptions` override + whose value is not a string raises it from the constructor too. +- **A bug reaches you raw** — a `baseUrl` that does not parse, or a non-file value where a `FileInput` + was declared, is a plain `TypeError` naming no call. +- **A caller abort arrives as the signal's own `reason`**, unwrapped — whatever you passed to + `abort()`, or the `DOMException` a bare `abort()` supplies. It is yours, not the SDK's, so it is + deliberately not in the family, and never retried. The per-attempt `retry.timeout` is the SDK's own + and **does** stay in it, as a `TimeoutError`. + +`ConfigurationError` and `SchemaError` are exported from the package root; the other two are not the +SDK's to export. + +## `"api"` — the API answered ```ts -class ResponseError